Compare commits

...
Author SHA1 Message Date
56a286bf1f Revert "feat: mark fields with nullable type as Optional"
This reverts commit 7b14c58230.
2026-08-29 01:35:57 +02:00
1bca38e632 test: add unit tests 2026-08-29 01:33:08 +02:00
7b14c58230 feat: mark fields with nullable type as Optional 2026-08-29 01:32:47 +02:00
13 changed files with 550 additions and 0 deletions

View file

@ -12,10 +12,19 @@ kotlin {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
tasks.test {
useJUnitPlatform()
}
dependencies {
implementation(libs.kotlin.reflect)
implementation(libs.kotlinx.coroutines.core)
testImplementation(platform("org.junit:junit-bom:5.14.4"))
testImplementation("org.junit.jupiter:junit-jupiter:5.14.4")
testImplementation("org.amshove.kluent:kluent:1.71")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
apply(from = "$rootDir/scripts/publish-root.gradle.kts")

View file

@ -0,0 +1,100 @@
package de.thetadev.formconductor.builder
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.FormField
import de.thetadev.formconductor.FormFieldImpl
import de.thetadev.formconductor.FormImpl
import de.thetadev.formconductor.annotations.Form
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.should
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be instance of`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FormBuilderTest {
@Form
data class MockForm(
val arg1: String = ""
)
@Test
fun `form builder function returns correct instance`() {
val form = form(MockForm::class)
form `should be instance of` FormImpl::class
form `should be instance of` de.thetadev.formconductor.Form::class
}
@Test
fun `form builder function returns correct builder scope`() = runTest {
form(MockForm::class) {
this `should be instance of` FormBuilderScopeImpl::class
this `should be instance of` FormBuilderScope::class
this.formState `should be instance of` Flow::class
}
}
@Test
fun `form builder scope functions properly`() = runTest {
form(MockForm::class) {
this.registerField(MockForm::arg1) `should be instance of` FormFieldImpl::class
this.registerField(MockForm::arg1) `should be instance of` FormField::class
}
}
@Test
fun `field builder function returns correct result`() = runTest {
form(MockForm::class) {
val field = field(MockForm::arg1)
field `should be instance of` FormField::class
field.fieldName.trim() `should be equal to` MockForm::arg1.name.trim()
field.isFieldOptional() `should be` false
field.resultStream `should be instance of` MutableStateFlow::class
field.valueStream `should be instance of` MutableStateFlow::class
field.value should {
this == "" || this == null
}
}
}
@Test
fun `field builder scope functions properly`() = runTest {
form(MockForm::class) {
field(MockForm::arg1) {
this.setField("Test")
this.resultState.value `should be equal to` FieldResult.Success
this.state.value `should be equal to` "Test"
}
}
}
@Test
fun `field builder extension functions returns correct result`() = runTest {
val form = form(MockForm::class) {}
val field = form.field(MockForm::arg1)
field `should be instance of` FormField::class
field.fieldName.trim() `should be equal to` MockForm::arg1.name.trim()
field.isFieldOptional() `should be` false
field.resultStream `should be instance of` MutableStateFlow::class
field.valueStream `should be instance of` MutableStateFlow::class
field.value should {
this == "" || this == null
}
}
@Test
fun `field builder extension scope functions properly`() = runTest {
val form = form(MockForm::class) {}
form.field(MockForm::arg1) {
this.setField("Test")
this.resultState.value `should be equal to` FieldResult.Success
this.state.value `should be equal to` "Test"
}
}
}

View file

@ -0,0 +1,101 @@
package de.thetadev.formconductor.form
import de.thetadev.formconductor.FormField
import de.thetadev.formconductor.FormImpl
import de.thetadev.formconductor.FormResult
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.should
import org.amshove.kluent.`should be`
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be instance of`
import org.amshove.kluent.`should not be`
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FormImplBehaviorTest {
private lateinit var form: FormImpl<TestForm>
@BeforeEach
fun setUp() {
form = FormImpl(TestForm::class)
}
@Nested
inner class DataStreams {
@Test
fun `form data stream emits a non-null value as first item`() = runTest {
form.formDataStream.firstOrNull() `should not be` null
}
@Test
fun `form data stream emits NoInput as first item`() = runTest {
val firstEmittedItem = form.formDataStream.firstOrNull()
firstEmittedItem `should be` FormResult.NoInput
}
}
@Nested
inner class FormConstruction {
/*
* Type checks
*/
@Test
fun `form construction should fail on field type and annotation mismatch`() {
assertThrows<IllegalArgumentException> {
FormImpl(MockedForms.AnnotationPropertyTypeMismatch::class)
}
}
@Test
fun `form construction should fail on field type and annotation multiple mismatches`() {
assertThrows<IllegalArgumentException> {
FormImpl(MockedForms.AnnotationPropertyTypeMismatch2::class)
}
}
}
@Nested
inner class FieldHandling {
@Test
fun `registerField returns correct field`() {
val field = form.registerField(TestForm::integerField)
field `should be instance of` FormField::class
field.fieldName.trim() `should be equal to` TestForm::integerField.name.trim()
field.isFieldOptional() `should be` false
field.resultStream `should be instance of` MutableStateFlow::class
field.valueStream `should be instance of` MutableStateFlow::class
field.value should {
this == 0 || this == null
}
}
@Test
fun `registerField returns correct field 2`() {
val field = form.registerField(TestForm::stringField)
field `should be instance of` FormField::class
field.fieldName.trim() `should be equal to` TestForm::stringField.name.trim()
field.isFieldOptional() `should be` false
field.resultStream `should be instance of` MutableStateFlow::class
field.valueStream `should be instance of` MutableStateFlow::class
field.value should {
this == "" || this == null
}
}
}
}

View file

@ -0,0 +1,45 @@
package de.thetadev.formconductor.form
import de.thetadev.formconductor.FormImpl
import de.thetadev.formconductor.FormResult
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.`should be equal to`
import org.amshove.kluent.`should be instance of`
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FormResultTest {
private lateinit var form: FormImpl<TestForm>
@BeforeEach
fun setUp() {
form = FormImpl(TestForm::class)
}
@Test
fun `submit returns correct result for no input`() {
form.validate() `should be instance of` FormResult.Error::class
}
@Test
fun `submit returns success result for valid input`() = runTest {
val expectedResult = FormResult.Valid(TestForm(stringField = "Test", integerField = 10))
form.setField(TestForm::stringField, "Test")
form.setField(TestForm::integerField, 10)
val result = form.validate()
result `should be equal to` expectedResult
}
@Test
fun `submit returns error result for invalid input`() = runTest {
form.setField(TestForm::stringField, "Test")
form.setField(TestForm::integerField, 9)
val result = form.validate()
result `should be instance of` FormResult.Error::class
}
}

View file

@ -0,0 +1,19 @@
package de.thetadev.formconductor.form
import de.thetadev.formconductor.annotations.EmailAddress
import de.thetadev.formconductor.annotations.IntegerRange
object MockedForms {
data class AnnotationPropertyTypeMismatch(
@IntegerRange(0, 1)
val arg1: String = ""
)
data class AnnotationPropertyTypeMismatch2(
val arg1: String = "",
@EmailAddress
val arg2: Int = 0,
@IntegerRange(0, 1)
val arg3: String = ""
)
}

View file

@ -0,0 +1,9 @@
package de.thetadev.formconductor.form
import de.thetadev.formconductor.annotations.IntegerRange
data class TestForm(
val stringField: String = "",
@IntegerRange(min = 10, max = 100)
val integerField: Int = 0
)

View file

@ -0,0 +1,63 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FormImpl
import de.thetadev.formconductor.FormResult
import de.thetadev.formconductor.annotations.EmailAddress
import de.thetadev.formconductor.annotations.Form
import de.thetadev.formconductor.annotations.OptionalField
import de.thetadev.formconductor.validation.rules.EmailAddressRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class EmailAddressRuleTest {
@Form
data class MockForm(
@EmailAddress
val email: String = "",
@EmailAddress
@OptionalField
val emailOpt: String? = null
)
private lateinit var form: FormImpl<MockForm>
@BeforeEach
fun setUp() {
form = FormImpl(MockForm::class)
}
@Test
fun `rule yields correct success result for annotated validations`() = runTest {
val formData = MockForm(email = "hello@example.com")
form.setValues(formData)
form.validate() `should be equal to` FormResult.Valid(formData)
}
@Test
fun `rule yields correct success result for both annotated validations`() = runTest {
val formData = MockForm(email = "hello@example.com", emailOpt = "bye@example.com")
form.setValues(formData)
form.validate() `should be equal to` FormResult.Valid(formData)
}
@Test
fun `rule yields correct error result for annotated validations`() = runTest {
val formData = MockForm(email = "hello")
form.setValues(formData)
form.validate() `should be equal to` FormResult.Error(setOf(EmailAddressRule))
}
@Test
fun `rule yields correct error result for opt annotated validation`() = runTest {
val formData = MockForm(email = "hello@example.com", emailOpt = "bye")
form.setValues(formData)
form.validate() `should be equal to` FormResult.Error(setOf(EmailAddressRule))
}
}

View file

@ -0,0 +1,44 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.annotations.FloatRange
import de.thetadev.formconductor.validation.rules.FloatRangeRule
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FloatRangeRuleTest {
@Test
fun `rule return success for correct values`() {
val options = FloatRange(min = 0.0, max = 1.0)
FloatRangeRule.validate(0.0f, options) `should be equal to` FieldResult.Success
FloatRangeRule.validate(0.1f, options) `should be equal to` FieldResult.Success
FloatRangeRule.validate(1.0f, options) `should be equal to` FieldResult.Success
}
@Test
fun `rule return error for invalid values`() {
val options = FloatRange(min = 0.0, max = 1.0)
FloatRangeRule.validate(
-0.1f,
options
) `should be equal to` FieldResult.Error("Value out of range", FloatRangeRule)
FloatRangeRule.validate(
10f,
options
) `should be equal to` FieldResult.Error("Value out of range", FloatRangeRule)
FloatRangeRule.validate(
1.001f,
options
) `should be equal to` FieldResult.Error("Value out of range", FloatRangeRule)
}
}

View file

@ -0,0 +1,37 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.annotations.IntegerRange
import de.thetadev.formconductor.validation.rules.IntegerRangeRule
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class IntegerRangeRuleTest {
@Test
fun `rule return success for correct values`() {
val options = IntegerRange(min = 0, max = 10)
IntegerRangeRule.validate(0, options) `should be equal to` FieldResult.Success
IntegerRangeRule.validate(5, options) `should be equal to` FieldResult.Success
IntegerRangeRule.validate(10, options) `should be equal to` FieldResult.Success
}
@Test
fun `rule return error for invalid values`() {
val options = IntegerRange(min = 0, max = 10)
IntegerRangeRule.validate(
-1,
options
) `should be equal to` FieldResult.Error("Value out of range", IntegerRangeRule)
IntegerRangeRule.validate(
11,
options
) `should be equal to` FieldResult.Error("Value out of range", IntegerRangeRule)
}
}

View file

@ -0,0 +1,25 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.annotations.IsChecked
import de.thetadev.formconductor.validation.rules.IsCheckedRule
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class IsCheckedRuleTest {
private val options = IsChecked()
@Test
fun `rule returns success for correct input`() {
IsCheckedRule.validate(true, options) `should be equal to` FieldResult.Success
}
@Test
fun `rule returns error for invalid input`() {
val expectedResult =
FieldResult.Error("This field is required to be checked", IsCheckedRule)
IsCheckedRule.validate(false, options) `should be equal to` expectedResult
}
}

View file

@ -0,0 +1,27 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.annotations.MaxLength
import de.thetadev.formconductor.validation.rules.MaxLengthRule
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MaxLengthRuleTest {
@Test
fun `rule returns success for correct input`() {
val options = MaxLength(5)
MaxLengthRule.validate("12345", options) `should be equal to` FieldResult.Success
MaxLengthRule.validate("1234", options) `should be equal to` FieldResult.Success
MaxLengthRule.validate("1", options) `should be equal to` FieldResult.Success
}
@Test
fun `rule returns error for invalid input`() {
val options = MaxLength(5)
val expectedResult =
FieldResult.Error("Value shouldn't be longer than ${options.value}.", MaxLengthRule)
MaxLengthRule.validate("123456", options) `should be equal to` expectedResult
}
}

View file

@ -0,0 +1,28 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.annotations.MinLength
import de.thetadev.formconductor.validation.rules.MinLengthRule
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class MinLengthRuleTest {
@Test
fun `rule returns success for correct input`() {
val options = MinLength(5)
MinLengthRule.validate("123456", options) `should be equal to` FieldResult.Success
MinLengthRule.validate("12345", options) `should be equal to` FieldResult.Success
}
@Test
fun `rule returns error for invalid input`() {
val options = MinLength(5)
val expectedResult =
FieldResult.Error("Value shouldn't be shorter than ${options.value}.", MinLengthRule)
MinLengthRule.validate("1234", options) `should be equal to` expectedResult
MinLengthRule.validate("1", options) `should be equal to` expectedResult
}
}

View file

@ -0,0 +1,43 @@
package de.thetadev.formconductor.validation
import de.thetadev.formconductor.FieldResult
import de.thetadev.formconductor.annotations.WebUrl
import de.thetadev.formconductor.validation.rules.WebUrlRule
import org.amshove.kluent.`should be equal to`
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class WebUrlRuleTest {
@Test
fun `rule returns success result for correct url input`() {
val options = WebUrl()
WebUrlRule.validate("https://google.com", options) `should be equal to` FieldResult.Success
WebUrlRule.validate("www.google.com", options) `should be equal to` FieldResult.Success
WebUrlRule.validate("google.com", options) `should be equal to` FieldResult.Success
}
@Test
fun `rule returns error for invalid url input`() {
val options = WebUrl()
val expectedResult = FieldResult.Error("Invalid URL", WebUrlRule)
WebUrlRule.validate("https://abc", options) `should be equal to` expectedResult
WebUrlRule.validate("random", options) `should be equal to` expectedResult
}
@Test
fun `rule returns success result for correct https url input`() {
val options = WebUrl(httpRequired = true)
WebUrlRule.validate("https://google.com", options) `should be equal to` FieldResult.Success
WebUrlRule.validate("http://google.com", options) `should be equal to` FieldResult.Success
}
@Test
fun `rule returns error for invalid https url input`() {
val options = WebUrl(true)
val expectedResult = FieldResult.Error("Invalid URL", WebUrlRule)
WebUrlRule.validate("https://abc", options) `should be equal to` expectedResult
WebUrlRule.validate("random", options) `should be equal to` expectedResult
WebUrlRule.validate("google.com", options) `should be equal to` expectedResult
}
}