test: add comprehensive test coverage for modifiers, integration, ViewModel, and UI dialogs (Closes #108) #118

Merged
shahondin1624 merged 1 commits from feature/issue-108-test-coverage-gaps into main 2026-04-04 21:22:06 +02:00
9 changed files with 1045 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# Plan: Issue #108 - Test Coverage Gaps
## Summary
Add comprehensive tests for the modifier system (cache behavior, accumulation, type safety),
integration tests (attribute change -> damage monitor recalculation), and ViewModel-like unit tests
for save/load/error paths. Also add UI tests for edit dialog validation.
## Implementation Steps
1. **ModifierSystemTest.kt** - Tests for:
- ModifierCache LRU eviction (max 5 entries)
- ModifierCache cache hit behavior
- AttributeModifier application to Attributes
- accumulateModifiers fold for DiceRoll
- Type safety of modifier interfaces
2. **IntegrationTest.kt** - Tests for:
- Attribute change -> DamageMonitor max value recalculation
- Full character round-trip: create, modify attributes, verify derived values
- Serialization round-trip preserving modifier behavior
3. **CharacterViewModelTest.kt** - Tests for:
- Save/serialize path (DataLoader.serialize)
- Load/deserialize path with valid JSON
- Error handling on malformed JSON (falls back to EXAMPLE_CHARACTER)
- Character update transform function
4. **EditDialogValidationTest.kt** - Tests for:
- CharacterData validation (checking field constraints)
- Attribute edit value bounds
## AC Verification Checklist
1. [ ] ViewModel tests for save/load/error paths
2. [ ] Integration test: modify attribute -> verify damage monitor updates
3. [ ] Modifier system tests: cache hits, accumulation, type safety
4. [ ] UI tests for edit dialogs with validation
5. [ ] Test coverage report available (document how to generate)
+14
View File
@@ -28,6 +28,20 @@ Single test class:
Uses `androidx.compose.ui.test.runComposeUiTest` for Compose UI testing. Uses `androidx.compose.ui.test.runComposeUiTest` for Compose UI testing.
### Test Coverage
Generate test coverage reports using Kover:
```
./gradlew :sharedUI:koverHtmlReport
```
Reports are generated to `sharedUI/build/reports/kover/html/`. Open `index.html` to view coverage details.
For a quick coverage summary in the console:
```
./gradlew :sharedUI:koverLog
```
## Architecture ## Architecture
Kotlin Multiplatform project using Compose Multiplatform. All shared logic and UI lives in `sharedUI`; platform modules are thin entry points. Kotlin Multiplatform project using Compose Multiplatform. All shared logic and UI lives in `sharedUI`; platform modules are thin entry points.
+1
View File
@@ -9,4 +9,5 @@ plugins {
alias(libs.plugins.compose.hot.reload).apply(false) alias(libs.plugins.compose.hot.reload).apply(false)
alias(libs.plugins.kotlinx.serialization).apply(false) alias(libs.plugins.kotlinx.serialization).apply(false)
alias(libs.plugins.metro).apply(false) alias(libs.plugins.metro).apply(false)
alias(libs.plugins.kover).apply(false)
} }
+2
View File
@@ -11,6 +11,7 @@ androidx-lifecycle = "2.9.4"
androidx-navigation = "2.9.0" androidx-navigation = "2.9.0"
kotlinx-serialization = "1.9.0" kotlinx-serialization = "1.9.0"
metro = "0.6.8" metro = "0.6.8"
kover = "0.9.1"
coil = "3.3.0" coil = "3.3.0"
multiplatformSettings = "1.3.0" multiplatformSettings = "1.3.0"
kstore = "1.0.0" kstore = "1.0.0"
@@ -61,3 +62,4 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
compose-hot-reload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } compose-hot-reload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" } metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
+1
View File
@@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.compose.hot.reload) alias(libs.plugins.compose.hot.reload)
alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.metro) alias(libs.plugins.metro)
alias(libs.plugins.kover)
} }
kotlin { kotlin {
@@ -0,0 +1,219 @@
package org.shahondin1624
import org.shahondin1624.lib.functions.DataLoader
import org.shahondin1624.model.EXAMPLE_CHARACTER
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Metatype
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.model.createNewCharacter
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests for ViewModel-related save/load/error paths.
*
* Since CharacterViewModel depends on platform-specific Settings and
* viewModelScope, these tests validate the underlying DataLoader
* serialize/deserialize logic that the ViewModel delegates to, plus
* the character state transformation patterns used by the ViewModel.
*/
class CharacterViewModelTest {
// ---- Serialization (save path) ----
@Test
fun serializeProducesValidJson() {
val json = DataLoader.serialize(EXAMPLE_CHARACTER)
assertTrue(json.isNotBlank(), "Serialized JSON should not be blank")
assertTrue(json.contains("Max Mustermann"), "JSON should contain character name")
assertTrue(json.contains("\"nuyen\""), "JSON should contain nuyen field")
}
@Test
fun serializeIncludesAllCharacterFields() {
val json = DataLoader.serialize(EXAMPLE_CHARACTER)
// Verify key fields are present
assertTrue(json.contains("\"characterData\""), "Should contain characterData")
assertTrue(json.contains("\"attributes\""), "Should contain attributes")
assertTrue(json.contains("\"talents\""), "Should contain talents")
assertTrue(json.contains("\"damageMonitor\""), "Should contain damageMonitor")
}
@Test
fun serializeNewCharacterWorks() {
val newChar = createNewCharacter()
val json = DataLoader.serialize(newChar)
assertTrue(json.isNotBlank())
assertTrue(json.contains("New Character"))
}
// ---- Deserialization (load path) ----
@Test
fun deserializeValidJsonRestoresCharacter() {
val json = DataLoader.serialize(EXAMPLE_CHARACTER)
val restored = DataLoader.deserialize(json)
assertEquals(EXAMPLE_CHARACTER.characterData.name, restored.characterData.name)
assertEquals(EXAMPLE_CHARACTER.characterData.metatype, restored.characterData.metatype)
assertEquals(EXAMPLE_CHARACTER.characterData.nuyen, restored.characterData.nuyen)
assertEquals(EXAMPLE_CHARACTER.characterData.essence, restored.characterData.essence)
assertEquals(EXAMPLE_CHARACTER.characterData.totalKarma, restored.characterData.totalKarma)
assertEquals(EXAMPLE_CHARACTER.characterData.currentKarma, restored.characterData.currentKarma)
}
@Test
fun deserializePreservesAttributeValues() {
val json = DataLoader.serialize(EXAMPLE_CHARACTER)
val restored = DataLoader.deserialize(json)
assertEquals(EXAMPLE_CHARACTER.attributes.body(), restored.attributes.body())
assertEquals(EXAMPLE_CHARACTER.attributes.agility(), restored.attributes.agility())
assertEquals(EXAMPLE_CHARACTER.attributes.reaction(), restored.attributes.reaction())
assertEquals(EXAMPLE_CHARACTER.attributes.strength(), restored.attributes.strength())
assertEquals(EXAMPLE_CHARACTER.attributes.willpower(), restored.attributes.willpower())
assertEquals(EXAMPLE_CHARACTER.attributes.logic(), restored.attributes.logic())
assertEquals(EXAMPLE_CHARACTER.attributes.intuition(), restored.attributes.intuition())
assertEquals(EXAMPLE_CHARACTER.attributes.charisma(), restored.attributes.charisma())
assertEquals(EXAMPLE_CHARACTER.attributes.edge, restored.attributes.edge)
}
@Test
fun deserializePreservesTalentCount() {
val json = DataLoader.serialize(EXAMPLE_CHARACTER)
val restored = DataLoader.deserialize(json)
assertEquals(
EXAMPLE_CHARACTER.talents.talents.size,
restored.talents.talents.size,
"Talent count should be preserved"
)
}
@Test
fun deserializePreservesDamageValues() {
val character = EXAMPLE_CHARACTER.copy(
damageMonitor = EXAMPLE_CHARACTER.damageMonitor
.withAttributes(EXAMPLE_CHARACTER.attributes)
.withPhysicalDamage(3)
.withStunDamage(2)
)
val json = DataLoader.serialize(character)
val restored = DataLoader.deserialize(json)
assertEquals(3, restored.damageMonitor.physicalCurrent(), "Physical damage should be preserved")
assertEquals(2, restored.damageMonitor.stunCurrent(), "Stun damage should be preserved")
}
// ---- Error handling (malformed input) ----
@Test
fun deserializeMalformedJsonThrowsException() {
assertFailsWith<Exception>("Malformed JSON should throw an exception") {
DataLoader.deserialize("this is not valid json")
}
}
@Test
fun deserializeEmptyJsonThrowsException() {
assertFailsWith<Exception>("Empty JSON should throw an exception") {
DataLoader.deserialize("")
}
}
@Test
fun deserializeIncompleteJsonThrowsException() {
assertFailsWith<Exception>("Incomplete JSON should throw an exception") {
DataLoader.deserialize("{\"characterData\": {}}")
}
}
// ---- Character transformation (updateCharacter pattern) ----
@Test
fun updateCharacterTransformModifiesCharacter() {
var character = EXAMPLE_CHARACTER
// Simulate ViewModel.updateCharacter transform
val transform: (ShadowrunCharacter) -> ShadowrunCharacter = { c ->
c.copy(
characterData = c.characterData.copy(name = "Updated Name")
)
}
character = transform(character)
assertEquals("Updated Name", character.characterData.name)
}
@Test
fun updateAttributeTransformPreservesOtherData() {
var character = EXAMPLE_CHARACTER
val transform: (ShadowrunCharacter) -> ShadowrunCharacter = { c ->
val newAttrs = c.attributes.withAttribute(AttributeType.Body, 8)
c.copy(
attributes = newAttrs,
damageMonitor = c.damageMonitor.withAttributes(newAttrs)
)
}
character = transform(character)
assertEquals(8, character.attributes.body())
assertEquals(EXAMPLE_CHARACTER.characterData.name, character.characterData.name)
// Damage monitor should reflect new body
assertEquals(11, character.damageMonitor.physicalMax(), "physicalMax = 7 + (8/2) = 11")
}
@Test
fun updateTalentRatingTransform() {
var character = EXAMPLE_CHARACTER
val talentName = character.talents.talents.first().name
val transform: (ShadowrunCharacter) -> ShadowrunCharacter = { c ->
c.copy(talents = c.talents.withTalentRating(talentName, 5))
}
character = transform(character)
val updated = character.talents.talents.first { it.name == talentName }
assertEquals(5, updated.value, "Talent rating should be updated to 5")
}
// ---- Round-trip save/load preserves modifications ----
@Test
fun saveAndLoadAfterModification() {
val modified = EXAMPLE_CHARACTER.copy(
characterData = EXAMPLE_CHARACTER.characterData.copy(
name = "Test Runner",
nuyen = 99999,
currentKarma = 10,
totalKarma = 50
)
)
val json = DataLoader.serialize(modified)
val restored = DataLoader.deserialize(json)
assertEquals("Test Runner", restored.characterData.name)
assertEquals(99999, restored.characterData.nuyen)
assertEquals(10, restored.characterData.currentKarma)
assertEquals(50, restored.characterData.totalKarma)
}
@Test
fun saveAndLoadNotesAndLifestyles() {
val modified = EXAMPLE_CHARACTER.copy(
notes = "Test notes for the character"
)
val json = DataLoader.serialize(modified)
val restored = DataLoader.deserialize(json)
assertEquals("Test notes for the character", restored.notes)
}
}
@@ -0,0 +1,274 @@
package org.shahondin1624
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.runComposeUiTest
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.charactermodel.CharacterDataEditDialog
import org.shahondin1624.lib.components.charactermodel.attributespage.AttributeEditDialog
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Metatype
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* UI tests for edit dialog validation behavior:
* - AttributeEditDialog value bounds checking
* - CharacterDataEditDialog field validation
*/
@OptIn(ExperimentalTestApi::class)
class EditDialogValidationTest {
private val testAttribute = Attribute(AttributeType.Body, 3)
private val testCharacterData = CharacterData(
concept = "Test",
nuyen = 1000,
essence = 6.0f,
name = "Test Character",
metatype = Metatype.Human,
age = 25,
gender = "Female",
streetCred = 0,
notoriety = 0,
publicAwareness = 0,
totalKarma = 20,
currentKarma = 10
)
// ---- AttributeEditDialog validation ----
@Test
fun attributeEditDialogConfirmEnabledForValidValue() = runComposeUiTest {
setContent {
AttributeEditDialog(
attribute = testAttribute,
maxValue = 10,
onConfirm = {},
onDismiss = {}
)
}
// Default value (3) should be valid
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_CONFIRM).assertIsEnabled()
}
@Test
fun attributeEditDialogConfirmDisabledWhenEmpty() = runComposeUiTest {
setContent {
AttributeEditDialog(
attribute = testAttribute,
maxValue = 10,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextClearance()
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun attributeEditDialogConfirmDisabledForZero() = runComposeUiTest {
setContent {
AttributeEditDialog(
attribute = testAttribute,
maxValue = 10,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextClearance()
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextInput("0")
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun attributeEditDialogConfirmDisabledForValueExceedingMax() = runComposeUiTest {
setContent {
AttributeEditDialog(
attribute = testAttribute,
maxValue = 10,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextClearance()
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextInput("15")
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun attributeEditDialogAcceptsMaxBoundaryValue() = runComposeUiTest {
setContent {
AttributeEditDialog(
attribute = testAttribute,
maxValue = 10,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextClearance()
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextInput("10")
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_CONFIRM).assertIsEnabled()
}
@Test
fun attributeEditDialogAcceptsMinBoundaryValue() = runComposeUiTest {
setContent {
AttributeEditDialog(
attribute = testAttribute,
maxValue = 10,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextClearance()
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_INPUT).performTextInput("1")
onNodeWithTag(TestTags.ATTRIBUTE_EDIT_CONFIRM).assertIsEnabled()
}
// ---- CharacterDataEditDialog validation ----
@Test
fun characterDataEditConfirmEnabledForValidData() = runComposeUiTest {
setContent {
CharacterDataEditDialog(
characterData = testCharacterData,
onConfirm = {},
onDismiss = {}
)
}
// Default valid data should enable confirm
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM).assertIsEnabled()
}
@Test
fun characterDataEditConfirmDisabledWhenNameEmpty() = runComposeUiTest {
setContent {
CharacterDataEditDialog(
characterData = testCharacterData,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_NAME).performTextClearance()
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun characterDataEditConfirmDisabledWhenAgeEmpty() = runComposeUiTest {
setContent {
CharacterDataEditDialog(
characterData = testCharacterData,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_AGE).performTextClearance()
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun characterDataEditConfirmDisabledWhenNuyenEmpty() = runComposeUiTest {
setContent {
CharacterDataEditDialog(
characterData = testCharacterData,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_NUYEN).performTextClearance()
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun characterDataEditConfirmDisabledWhenEssenceEmpty() = runComposeUiTest {
setContent {
CharacterDataEditDialog(
characterData = testCharacterData,
onConfirm = {},
onDismiss = {}
)
}
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_ESSENCE).performTextClearance()
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM).assertIsNotEnabled()
}
@Test
fun characterDataEditConfirmDisabledWhenCurrentKarmaExceedsTotalKarma() = runComposeUiTest {
setContent {
CharacterDataEditDialog(
characterData = testCharacterData.copy(totalKarma = 10, currentKarma = 5),
onConfirm = {},
onDismiss = {}
)
}
// Set current karma higher than total karma
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CURRENT_KARMA).performTextClearance()
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CURRENT_KARMA).performTextInput("15")
onNodeWithTag(TestTags.CHARACTER_DATA_EDIT_CONFIRM).assertIsNotEnabled()
}
// ---- Validation logic unit tests (non-UI) ----
@Test
fun characterDataValidationNameNotBlank() {
val valid = testCharacterData.copy(name = "Valid Name")
assertTrue(valid.name.isNotBlank(), "Name should be valid when not blank")
val invalid = testCharacterData.copy(name = "")
assertFalse(invalid.name.isNotBlank(), "Empty name should be invalid")
val whitespace = testCharacterData.copy(name = " ")
assertFalse(whitespace.name.isNotBlank(), "Whitespace-only name should be invalid")
}
@Test
fun characterDataValidationEssenceRange() {
// Valid range: 0.0 to 6.0
assertTrue(0.0f in 0.0f..6.0f, "0.0 should be valid")
assertTrue(6.0f in 0.0f..6.0f, "6.0 should be valid")
assertTrue(3.5f in 0.0f..6.0f, "3.5 should be valid")
assertFalse(-0.1f in 0.0f..6.0f, "Negative essence should be invalid")
assertFalse(6.1f in 0.0f..6.0f, "Essence > 6.0 should be invalid")
}
@Test
fun characterDataValidationKarmaConstraint() {
// currentKarma must be <= totalKarma
val valid = testCharacterData.copy(totalKarma = 50, currentKarma = 30)
assertTrue(valid.currentKarma <= valid.totalKarma, "Current <= total should be valid")
val equal = testCharacterData.copy(totalKarma = 50, currentKarma = 50)
assertTrue(equal.currentKarma <= equal.totalKarma, "Current == total should be valid")
val invalid = testCharacterData.copy(totalKarma = 50, currentKarma = 51)
assertFalse(invalid.currentKarma <= invalid.totalKarma, "Current > total should be invalid")
}
@Test
fun characterDataValidationNonNegativeFields() {
assertTrue(testCharacterData.age >= 0, "Age should be non-negative")
assertTrue(testCharacterData.nuyen >= 0, "Nuyen should be non-negative")
assertTrue(testCharacterData.totalKarma >= 0, "Total karma should be non-negative")
assertTrue(testCharacterData.currentKarma >= 0, "Current karma should be non-negative")
}
}
@@ -0,0 +1,268 @@
package org.shahondin1624
import org.shahondin1624.lib.functions.DataLoader
import org.shahondin1624.model.EXAMPLE_CHARACTER
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.charactermodel.DamageMonitor
import org.shahondin1624.model.charactermodel.RecoveryState
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.model.charactermodel.createDamageMonitor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Integration tests verifying cross-component workflows:
* attribute changes propagating to damage monitors, full character round-trips,
* and serialization preserving behavior.
*/
class IntegrationTest {
private fun makeAttributes(body: Int = 3, willpower: Int = 3) = Attributes(
body = Attribute(AttributeType.Body, body),
agility = Attribute(AttributeType.Agility, 3),
reaction = Attribute(AttributeType.Reaction, 3),
strength = Attribute(AttributeType.Strength, 3),
willpower = Attribute(AttributeType.Willpower, willpower),
logic = Attribute(AttributeType.Logic, 3),
intuition = Attribute(AttributeType.Intuition, 3),
charisma = Attribute(AttributeType.Charisma, 3),
edge = 2
)
// ---- Attribute change -> DamageMonitor recalculation ----
@Test
fun changingBodyUpdatesPhysicalMax() {
val lowBody = makeAttributes(body = 2)
val highBody = makeAttributes(body = 8)
val monitorLow = DamageMonitor(lowBody)
val monitorHigh = DamageMonitor(highBody)
// physicalMax = 7 + (body / 2.0).toInt()
assertEquals(8, monitorLow.physicalMax(), "Low body (2): physicalMax = 7 + 1 = 8")
assertEquals(11, monitorHigh.physicalMax(), "High body (8): physicalMax = 7 + 4 = 11")
}
@Test
fun changingWillpowerUpdatesStunMax() {
val lowWill = makeAttributes(willpower = 2)
val highWill = makeAttributes(willpower = 8)
val monitorLow = DamageMonitor(lowWill)
val monitorHigh = DamageMonitor(highWill)
// stunMax = 7 + (willpower / 2.0).toInt()
assertEquals(8, monitorLow.stunMax(), "Low willpower (2): stunMax = 7 + 1 = 8")
assertEquals(11, monitorHigh.stunMax(), "High willpower (8): stunMax = 7 + 4 = 11")
}
@Test
fun changingBodyUpdatesOverflowMax() {
val lowBody = makeAttributes(body = 2)
val highBody = makeAttributes(body = 10)
val monitorLow = DamageMonitor(lowBody)
val monitorHigh = DamageMonitor(highBody)
// overflowMax = (body / 2.0).toInt()
assertEquals(1, monitorLow.overflowMax(), "Low body (2): overflowMax = 1")
assertEquals(5, monitorHigh.overflowMax(), "High body (10): overflowMax = 5")
}
@Test
fun withAttributesRecalculatesDamageMonitorLimits() {
val initialAttrs = makeAttributes(body = 4, willpower = 4)
val monitor = DamageMonitor(initialAttrs)
assertEquals(9, monitor.physicalMax(), "Initial physicalMax = 7 + 2 = 9")
assertEquals(9, monitor.stunMax(), "Initial stunMax = 7 + 2 = 9")
// Upgrade attributes
val upgradedAttrs = makeAttributes(body = 8, willpower = 6)
val upgradedMonitor = monitor.withAttributes(upgradedAttrs)
assertEquals(11, upgradedMonitor.physicalMax(), "Upgraded physicalMax = 7 + 4 = 11")
assertEquals(10, upgradedMonitor.stunMax(), "Upgraded stunMax = 7 + 3 = 10")
}
// ---- Full character attribute -> derived value flow ----
@Test
fun attributeChangeAffectsDerivedValues() {
val attrs = makeAttributes(body = 4, willpower = 4)
// Derived values use the attributes
val initiative = attrs.initiative() // reaction + intuition = 3 + 3 = 6
assertEquals(6, initiative)
val composure = attrs.composure() // body + willpower = 4 + 4 = 8
assertEquals(8, composure)
// Change body and verify
val newAttrs = attrs.withAttribute(AttributeType.Body, 6)
assertEquals(10, newAttrs.composure(), "Composure should update: 6 + 4 = 10")
// Initiative should be unchanged (doesn't depend on body)
assertEquals(6, newAttrs.initiative())
}
@Test
fun attributeChangePreservesOtherAttributes() {
val attrs = makeAttributes(body = 4, willpower = 5)
val updated = attrs.withAttribute(AttributeType.Body, 8)
assertEquals(8, updated.body(), "Body should be updated to 8")
assertEquals(5, updated.willpower(), "Willpower should remain 5")
assertEquals(3, updated.agility(), "Agility should remain 3")
assertEquals(2, updated.edge, "Edge should remain 2")
}
// ---- Full character round-trip: create, modify, verify ----
@Test
fun fullCharacterLifecycleWithDamageMonitor() {
val attrs = makeAttributes(body = 4, willpower = 4)
val character = ShadowrunCharacter(
characterData = EXAMPLE_CHARACTER.characterData,
attributes = attrs,
talents = EXAMPLE_CHARACTER.talents,
damageMonitor = DamageMonitor(attrs)
)
// Take some damage
val damaged = character.copy(
damageMonitor = character.damageMonitor.withPhysicalDamage(5)
)
assertEquals(5, damaged.damageMonitor.physicalCurrent())
assertEquals(-1, damaged.damageMonitor.woundModifier(), "5/3 = -1 wound modifier")
// Upgrade body attribute and recalculate monitor
val newAttrs = damaged.attributes.withAttribute(AttributeType.Body, 8)
val upgraded = damaged.copy(
attributes = newAttrs,
damageMonitor = damaged.damageMonitor.withAttributes(newAttrs)
)
// Physical max should increase, damage stays the same
assertEquals(11, upgraded.damageMonitor.physicalMax(), "Physical max should now be 7 + 4 = 11")
assertEquals(5, upgraded.damageMonitor.physicalCurrent(), "Physical damage should still be 5")
}
@Test
fun recoveryStateReflectsDamageLevel() {
val attrs = makeAttributes(body = 4)
val monitor = DamageMonitor(attrs)
assertEquals(RecoveryState.Healthy, monitor.recoveryState())
val withStun = monitor.withStunDamage(2)
assertEquals(RecoveryState.Injured, withStun.recoveryState())
val withPhysical = monitor.withPhysicalDamage(monitor.physicalMax())
assertEquals(RecoveryState.Critical, withPhysical.recoveryState())
val withOverflow = withPhysical.withOverflowDamage(1)
assertEquals(RecoveryState.BleedingOut, withOverflow.recoveryState())
val dead = withPhysical.withOverflowDamage(monitor.overflowMax())
assertEquals(RecoveryState.Dead, dead.recoveryState())
}
// ---- Serialization round-trip preserves behavior ----
@Test
fun serializeDeserializePreservesCharacterData() {
val original = EXAMPLE_CHARACTER
val json = DataLoader.serialize(original)
val restored = DataLoader.deserialize(json)
assertEquals(original.characterData, restored.characterData)
assertEquals(original.attributes.body(), restored.attributes.body())
assertEquals(original.attributes.agility(), restored.attributes.agility())
assertEquals(original.attributes.edge, restored.attributes.edge)
}
@Test
fun deserializedCharacterHasWorkingDamageMonitor() {
val original = EXAMPLE_CHARACTER
val json = DataLoader.serialize(original)
val restored = DataLoader.deserialize(json)
// DataLoader.deserialize reconstitutes the DamageMonitor with actual attributes
// so derived values should work
assertEquals(original.damageMonitor.physicalMax(), restored.damageMonitor.physicalMax())
assertEquals(original.damageMonitor.stunMax(), restored.damageMonitor.stunMax())
assertEquals(original.damageMonitor.overflowMax(), restored.damageMonitor.overflowMax())
}
@Test
fun deserializedCharacterDamageMonitorIsLinkedToAttributes() {
val attrs = makeAttributes(body = 6, willpower = 4)
val character = ShadowrunCharacter(
characterData = EXAMPLE_CHARACTER.characterData,
attributes = attrs,
talents = EXAMPLE_CHARACTER.talents,
damageMonitor = DamageMonitor(attrs)
)
val json = DataLoader.serialize(character)
val restored = DataLoader.deserialize(json)
// Verify that the deserialized damage monitor was reconstituted with attributes
// physicalMax = 7 + (6 / 2.0).toInt() = 10
assertEquals(10, restored.damageMonitor.physicalMax())
// stunMax = 7 + (4 / 2.0).toInt() = 9
assertEquals(9, restored.damageMonitor.stunMax())
}
// ---- Talent test rolls use correct combined pool ----
@Test
fun talentTestUsesAttributePlusTalentRating() {
val attrs = makeAttributes() // all attributes at 3
val talents = EXAMPLE_CHARACTER.talents
// Find a talent and set it to rating 4
val updatedTalents = talents.withTalentRating(talents.talents.first().name, 4)
val talent = updatedTalents.talents.first()
// The test() method should roll attribute value + talent value dice
val attrValue = attrs.getAttributeByType(talent.attribute).value
val expectedPool = attrValue + 4
val roll = talent.test(emptyList(), attrs)
assertEquals(expectedPool, roll.result.size, "Roll should use attribute ($attrValue) + talent (4) = $expectedPool dice")
}
// ---- Derived attribute calculations ----
@Test
fun allDerivedAttributesCalculateCorrectly() {
val attrs = Attributes(
body = Attribute(AttributeType.Body, 4),
agility = Attribute(AttributeType.Agility, 5),
reaction = Attribute(AttributeType.Reaction, 3),
strength = Attribute(AttributeType.Strength, 6),
willpower = Attribute(AttributeType.Willpower, 4),
logic = Attribute(AttributeType.Logic, 3),
intuition = Attribute(AttributeType.Intuition, 5),
charisma = Attribute(AttributeType.Charisma, 2),
edge = 3
)
assertEquals(8, attrs.initiative(), "Initiative = reaction(3) + intuition(5) = 8")
assertEquals(3, attrs.matrixInitiative(), "Matrix initiative = logic(3)")
assertEquals(8, attrs.composure(), "Composure = body(4) + willpower(4) = 8")
assertEquals(7, attrs.judgeIntent(), "Judge Intent = intuition(5) + charisma(2) = 7")
assertEquals(7, attrs.memory(), "Memory = logic(3) + willpower(4) = 7")
assertEquals(12, attrs.carry(), "Carry = strength(6) * 2 = 12")
assertEquals(9, attrs.run(), "Run = agility(5) + body(4) = 9")
assertEquals(10, attrs.physicalLimit(), "Physical limit = body(4) + strength(6) = 10")
assertEquals(7, attrs.mentalLimit(), "Mental limit = willpower(4) + logic(3) = 7")
assertEquals(6, attrs.socialLimit(), "Social limit = charisma(2) + body(4) = 6")
}
}
@@ -0,0 +1,229 @@
package org.shahondin1624
import org.shahondin1624.lib.functions.DiceRoll
import org.shahondin1624.model.attributes.Attribute
import org.shahondin1624.model.attributes.AttributeType
import org.shahondin1624.model.attributes.Attributes
import org.shahondin1624.model.modifier.AttributeModifier
import org.shahondin1624.model.modifier.ModifierCache
import org.shahondin1624.model.modifier.SRModifier
import org.shahondin1624.model.modifier.accumulateModifiers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
/**
* Tests for the modifier system: ModifierCache LRU behavior,
* AttributeModifier application, and accumulateModifiers for DiceRoll.
*/
class ModifierSystemTest {
// --- Test helpers ---
private fun baseAttributes(body: Int = 3, agility: Int = 3) = Attributes(
body = Attribute(AttributeType.Body, body),
agility = Attribute(AttributeType.Agility, agility),
reaction = Attribute(AttributeType.Reaction, 3),
strength = Attribute(AttributeType.Strength, 3),
willpower = Attribute(AttributeType.Willpower, 3),
logic = Attribute(AttributeType.Logic, 3),
intuition = Attribute(AttributeType.Intuition, 3),
charisma = Attribute(AttributeType.Charisma, 3),
edge = 2
)
/** A simple modifier that adds a flat bonus to Body. */
private class BodyBonusModifier(private val bonus: Int) : AttributeModifier {
override fun apply(value: Attributes): Attributes {
val currentBody = value.body()
return value.withAttribute(AttributeType.Body, currentBody + bonus)
}
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> {
return object : SRModifier<DiceRoll> {
override fun apply(value: DiceRoll): DiceRoll = value.copy(numberOfDice = value.numberOfDice + bonus)
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> = this
}
}
// Implement equals/hashCode so cache key lookup works
override fun equals(other: Any?) = other is BodyBonusModifier && other.bonus == bonus
override fun hashCode() = bonus.hashCode()
}
/** A modifier that doubles agility. */
private class AgilityDoublerModifier : AttributeModifier {
override fun apply(value: Attributes): Attributes {
val currentAgility = value.agility()
return value.withAttribute(AttributeType.Agility, currentAgility * 2)
}
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> {
return object : SRModifier<DiceRoll> {
override fun apply(value: DiceRoll): DiceRoll = value.copy(numberOfDice = value.numberOfDice * 2)
override fun getDiceRollModifier(diceRoll: DiceRoll): SRModifier<DiceRoll> = this
}
}
override fun equals(other: Any?) = other is AgilityDoublerModifier
override fun hashCode() = "AgilityDoubler".hashCode()
}
// ---- AttributeModifier application ----
@Test
fun singleModifierIncreasesAttributeValue() {
val attrs = baseAttributes(body = 4)
val modifiers = listOf(BodyBonusModifier(2))
val cache = ModifierCache()
val result = cache.applyModifiers(modifiers, attrs)
assertEquals(6, result.body(), "Body should be 4 + 2 = 6")
}
@Test
fun multipleModifiersFoldSequentially() {
val attrs = baseAttributes(body = 3)
val modifiers = listOf(BodyBonusModifier(1), BodyBonusModifier(2))
val cache = ModifierCache()
val result = cache.applyModifiers(modifiers, attrs)
// First modifier: 3+1=4, second modifier: 4+2=6
assertEquals(6, result.body(), "Body should be 3 + 1 + 2 = 6")
}
@Test
fun differentModifierTypesApplyIndependently() {
val attrs = baseAttributes(body = 3, agility = 4)
val modifiers = listOf(BodyBonusModifier(2), AgilityDoublerModifier())
val cache = ModifierCache()
val result = cache.applyModifiers(modifiers, attrs)
assertEquals(5, result.body(), "Body should be 3 + 2 = 5")
assertEquals(8, result.agility(), "Agility should be 4 * 2 = 8")
}
@Test
fun emptyModifiersReturnUnchangedAttributes() {
val attrs = baseAttributes(body = 5)
val cache = ModifierCache()
val result = cache.applyModifiers(emptyList(), attrs)
assertEquals(5, result.body())
assertEquals(3, result.agility())
}
// ---- ModifierCache behavior ----
@Test
fun cacheReturnsSameInstanceOnHit() {
val attrs = baseAttributes()
val modifiers = listOf(BodyBonusModifier(1))
val cache = ModifierCache()
val first = cache.applyModifiers(modifiers, attrs)
val second = cache.applyModifiers(modifiers, attrs)
// Cache hit should return the exact same object reference
assertSame(first, second, "Cache should return the same instance on cache hit")
}
@Test
fun cacheMissReturnsNewInstance() {
val attrs = baseAttributes()
val cache = ModifierCache()
val first = cache.applyModifiers(listOf(BodyBonusModifier(1)), attrs)
val second = cache.applyModifiers(listOf(BodyBonusModifier(2)), attrs)
assertNotSame(first, second, "Different modifier lists should produce different instances")
}
@Test
fun cacheEvictsOldestEntryWhenExceedingMaxSize() {
val attrs = baseAttributes()
val cache = ModifierCache()
// Fill cache with 5 entries (max size)
val firstModifiers = listOf(BodyBonusModifier(1))
val firstResult = cache.applyModifiers(firstModifiers, attrs)
cache.applyModifiers(listOf(BodyBonusModifier(2)), attrs)
cache.applyModifiers(listOf(BodyBonusModifier(3)), attrs)
cache.applyModifiers(listOf(BodyBonusModifier(4)), attrs)
cache.applyModifiers(listOf(BodyBonusModifier(5)), attrs)
// Add a 6th entry — should evict the first
cache.applyModifiers(listOf(BodyBonusModifier(6)), attrs)
// Now re-request the first modifiers — should be a cache miss (new instance)
val recomputed = cache.applyModifiers(firstModifiers, attrs)
// Values should be equal
assertEquals(firstResult.body(), recomputed.body(), "Recomputed value should match")
// But it should be a new computation (different instance after eviction)
// Note: we cannot guarantee assertNotSame because data classes with same values
// may be structurally equal. So we just verify correctness.
assertEquals(4, recomputed.body(), "Body should still be 3 + 1 = 4 after eviction and recomputation")
}
@Test
fun cacheWithinCapacityRetainsAllEntries() {
val attrs = baseAttributes()
val cache = ModifierCache()
// Insert exactly 5 entries
val results = (1..5).map { i ->
val mods = listOf(BodyBonusModifier(i))
mods to cache.applyModifiers(mods, attrs)
}
// All 5 should be cache hits (same reference)
results.forEach { (mods, originalResult) ->
val cached = cache.applyModifiers(mods, attrs)
assertSame(originalResult, cached, "Entry should still be cached within capacity")
}
}
// ---- accumulateModifiers for DiceRoll ----
@Test
fun accumulateModifiersAppliesDiceRollModifications() {
val baseDice = DiceRoll(numberOfDice = 5)
val modifiers: List<SRModifier<*>> = listOf(BodyBonusModifier(3))
val result = modifiers.accumulateModifiers(baseDice)
assertEquals(8, result.numberOfDice, "Dice count should be 5 + 3 = 8")
}
@Test
fun accumulateModifiersWithEmptyListReturnsOriginal() {
val baseDice = DiceRoll(numberOfDice = 5)
val result = emptyList<SRModifier<*>>().accumulateModifiers(baseDice)
assertEquals(5, result.numberOfDice, "Empty modifier list should not change dice count")
}
@Test
fun accumulateModifiersFoldsMultipleModifiers() {
val baseDice = DiceRoll(numberOfDice = 4)
val modifiers: List<SRModifier<*>> = listOf(BodyBonusModifier(2), BodyBonusModifier(3))
val result = modifiers.accumulateModifiers(baseDice)
// First adds 2: 4+2=6, then adds 3: 6+3=9
assertEquals(9, result.numberOfDice, "Dice count should be 4 + 2 + 3 = 9")
}
// ---- Attribute.test() with modifiers ----
@Test
fun attributeTestRollsWithModifiedDicePool() {
val attr = Attribute(AttributeType.Body, 5)
val modifiers: List<SRModifier<*>> = listOf(BodyBonusModifier(3))
val roll = attr.test(modifiers)
// The roll should have used 5+3=8 dice
assertEquals(8, roll.result.size, "Roll should use modified dice pool of 8")
}
@Test
fun attributeTestWithNoModifiersUsesBaseValue() {
val attr = Attribute(AttributeType.Body, 6)
val roll = attr.test()
assertEquals(6, roll.result.size, "Roll should use base dice pool of 6")
}
}