feat: add version migration system for schema changes (Closes #104)
Implement a migration framework that automatically upgrades serialized character data from older schema versions to the current version on load. Includes MigrationRegistry for chaining migrations, a concrete v0.1->v0.2 migration, centralized SchemaVersion constant, and 17 unit tests covering migration paths, unknown fields, missing defaults, and error handling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
package org.shahondin1624.lib.functions
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
|
||||
import org.shahondin1624.model.migration.MigrationRegistry
|
||||
|
||||
/**
|
||||
* Platform-agnostic serialization helpers for ShadowrunCharacter.
|
||||
*
|
||||
* File I/O (reading/writing from disk) should be implemented per-platform
|
||||
* (androidMain, jvmMain, iosMain, webMain). Here we only convert to/from JSON strings.
|
||||
*
|
||||
* On deserialization, the version migration system is invoked automatically:
|
||||
* if the JSON has an older schema version, all necessary migrations are applied
|
||||
* before the data is decoded into [ShadowrunCharacter].
|
||||
*/
|
||||
object DataLoader {
|
||||
// Shared Json configuration used across the app
|
||||
@@ -24,7 +30,17 @@ object DataLoader {
|
||||
json.encodeToString(character)
|
||||
|
||||
fun deserialize(jsonString: String): ShadowrunCharacter {
|
||||
val character: ShadowrunCharacter = json.decodeFromString(jsonString)
|
||||
// Step 1: Parse to generic JSON tree
|
||||
val jsonElement = json.parseToJsonElement(jsonString).jsonObject
|
||||
|
||||
// Step 2: Apply version migrations if needed
|
||||
val migrated: JsonObject = MigrationRegistry.migrateToCurrentVersion(jsonElement)
|
||||
|
||||
// Step 3: Decode the (possibly migrated) JSON to ShadowrunCharacter
|
||||
val character: ShadowrunCharacter = json.decodeFromJsonElement(
|
||||
ShadowrunCharacter.serializer(), migrated
|
||||
)
|
||||
|
||||
// After deserialization, @Transient attributes in DamageMonitor defaults to EMPTY_ATTRIBUTES.
|
||||
// Reconstitute with the character's actual attributes.
|
||||
return character.copy(
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.shahondin1624.model.attributes
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.shahondin1624.model.Versionable
|
||||
import org.shahondin1624.model.migration.SchemaVersion
|
||||
import org.shahondin1624.model.modifier.AttributeModifier
|
||||
import org.shahondin1624.model.modifier.ModifierCache
|
||||
|
||||
@@ -19,7 +20,7 @@ data class Attributes(
|
||||
val edge: Int,
|
||||
@Transient
|
||||
private val cache: ModifierCache = ModifierCache(),
|
||||
override val version: String = "v0.1"
|
||||
override val version: String = SchemaVersion.CURRENT
|
||||
): Versionable {
|
||||
private fun applyModifiers(modifiers: List<AttributeModifier>): Attributes {
|
||||
return cache.applyModifiers(modifiers, this)
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package org.shahondin1624.model.characterdata
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.shahondin1624.model.Versionable
|
||||
import org.shahondin1624.model.migration.SchemaVersion
|
||||
|
||||
@Serializable
|
||||
data class CharacterData(
|
||||
@@ -17,5 +18,5 @@ data class CharacterData(
|
||||
val publicAwareness: Int,
|
||||
val totalKarma: Int,
|
||||
val currentKarma: Int,
|
||||
override val version: String = "v0.1"
|
||||
override val version: String = SchemaVersion.CURRENT
|
||||
): Versionable
|
||||
+2
-1
@@ -6,6 +6,7 @@ import org.shahondin1624.model.Versionable
|
||||
import org.shahondin1624.model.attributes.Attribute
|
||||
import org.shahondin1624.model.attributes.AttributeType
|
||||
import org.shahondin1624.model.attributes.Attributes
|
||||
import org.shahondin1624.model.migration.SchemaVersion
|
||||
import org.shahondin1624.model.modifier.AttributeModifier
|
||||
import org.shahondin1624.model.modifier.ModifierCache
|
||||
|
||||
@@ -34,7 +35,7 @@ data class DamageMonitor(
|
||||
private val overflowCurrent: Int = 0,
|
||||
@Transient
|
||||
private val cache: ModifierCache = ModifierCache(),
|
||||
override val version: String = "v0.1"
|
||||
override val version: String = SchemaVersion.CURRENT
|
||||
): Versionable {
|
||||
init {
|
||||
require(stunCurrent >= 0) { "Stun damage cannot be negative" }
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import org.shahondin1624.model.Versionable
|
||||
import org.shahondin1624.model.attributes.Attributes
|
||||
import org.shahondin1624.model.characterdata.CharacterData
|
||||
import org.shahondin1624.model.characterdata.Lifestyle
|
||||
import org.shahondin1624.model.migration.SchemaVersion
|
||||
import org.shahondin1624.model.talents.Talents
|
||||
|
||||
@Serializable
|
||||
@@ -15,5 +16,5 @@ data class ShadowrunCharacter(
|
||||
val damageMonitor: DamageMonitor,
|
||||
val notes: String = "",
|
||||
val lifestyles: List<Lifestyle> = emptyList(),
|
||||
override val version: String = "v0.1"
|
||||
override val version: String = SchemaVersion.CURRENT
|
||||
): Versionable
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.shahondin1624.model.migration
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Registry of all known version migrations. Applies migrations sequentially
|
||||
* to bring old schema versions up to [SchemaVersion.CURRENT].
|
||||
*/
|
||||
object MigrationRegistry {
|
||||
|
||||
private val migrations: List<VersionMigration> = listOf(
|
||||
MigrationV01ToV02()
|
||||
)
|
||||
|
||||
/**
|
||||
* Extract the version string from a root-level JSON object.
|
||||
* Returns [SchemaVersion.CURRENT] if no version field is present
|
||||
* (treats unversioned data as current to avoid breaking existing data).
|
||||
*/
|
||||
fun extractVersion(json: JsonObject): String {
|
||||
return json["version"]?.jsonPrimitive?.content ?: SchemaVersion.CURRENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given version needs migration to reach [SchemaVersion.CURRENT].
|
||||
*/
|
||||
fun needsMigration(version: String): Boolean {
|
||||
return version != SchemaVersion.CURRENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the ordered chain of migrations needed to go from [fromVersion]
|
||||
* to [SchemaVersion.CURRENT].
|
||||
*
|
||||
* @throws IllegalStateException if no migration path exists
|
||||
*/
|
||||
fun findMigrationChain(fromVersion: String): List<VersionMigration> {
|
||||
if (!needsMigration(fromVersion)) return emptyList()
|
||||
|
||||
val chain = mutableListOf<VersionMigration>()
|
||||
var currentVersion = fromVersion
|
||||
|
||||
while (currentVersion != SchemaVersion.CURRENT) {
|
||||
val migration = migrations.find { it.fromVersion == currentVersion }
|
||||
?: throw IllegalStateException(
|
||||
"No migration path from version '$currentVersion' to '${SchemaVersion.CURRENT}'. " +
|
||||
"Known migrations: ${migrations.map { "${it.fromVersion} -> ${it.toVersion}" }}"
|
||||
)
|
||||
chain.add(migration)
|
||||
currentVersion = migration.toVersion
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all necessary migrations to bring a JSON object from its current
|
||||
* version to [SchemaVersion.CURRENT].
|
||||
*
|
||||
* @param json The root JSON object (must contain a "version" field)
|
||||
* @return The migrated JSON object at the current schema version,
|
||||
* or the original object if no migration is needed
|
||||
* @throws IllegalStateException if no migration path exists for the detected version
|
||||
*/
|
||||
fun migrateToCurrentVersion(json: JsonObject): JsonObject {
|
||||
val version = extractVersion(json)
|
||||
if (!needsMigration(version)) return json
|
||||
|
||||
val chain = findMigrationChain(version)
|
||||
var current = json
|
||||
for (migration in chain) {
|
||||
current = migration.migrate(current)
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.shahondin1624.model.migration
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Migration from schema v0.1 to v0.2.
|
||||
*
|
||||
* Changes in v0.2:
|
||||
* - Ensures "notes" field exists on ShadowrunCharacter (default: "")
|
||||
* - Ensures "lifestyles" field exists on ShadowrunCharacter (default: [])
|
||||
* - Updates all version fields from "v0.1" to "v0.2"
|
||||
*
|
||||
* This migration serves as both a real migration for backward compatibility
|
||||
* with early saves and a template for future migrations.
|
||||
*/
|
||||
class MigrationV01ToV02 : VersionMigration {
|
||||
override val fromVersion: String = SchemaVersion.V0_1
|
||||
override val toVersion: String = SchemaVersion.V0_2
|
||||
|
||||
override fun migrate(json: JsonObject): JsonObject {
|
||||
val mutable = json.toMutableMap()
|
||||
|
||||
// Ensure top-level fields added after v0.1 exist with defaults
|
||||
if ("notes" !in mutable) {
|
||||
mutable["notes"] = JsonPrimitive("")
|
||||
}
|
||||
if ("lifestyles" !in mutable) {
|
||||
mutable["lifestyles"] = JsonArray(emptyList())
|
||||
}
|
||||
|
||||
// Update version fields at all levels
|
||||
mutable["version"] = JsonPrimitive(toVersion)
|
||||
mutable.updateNestedVersion("characterData")
|
||||
mutable.updateNestedVersion("attributes")
|
||||
mutable.updateNestedVersion("talents")
|
||||
mutable.updateNestedVersion("damageMonitor")
|
||||
|
||||
return JsonObject(mutable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the "version" field inside a nested JSON object.
|
||||
*/
|
||||
private fun MutableMap<String, JsonElement>.updateNestedVersion(key: String) {
|
||||
val nested = this[key]
|
||||
if (nested is JsonObject) {
|
||||
val nestedMutable = nested.toMutableMap()
|
||||
nestedMutable["version"] = JsonPrimitive(toVersion)
|
||||
this[key] = JsonObject(nestedMutable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.shahondin1624.model.migration
|
||||
|
||||
/**
|
||||
* Central schema version constant. All model classes should reference this
|
||||
* as their default version value.
|
||||
*/
|
||||
object SchemaVersion {
|
||||
const val CURRENT: String = "v0.2"
|
||||
|
||||
/**
|
||||
* Initial version used before the migration system was introduced.
|
||||
*/
|
||||
const val V0_1: String = "v0.1"
|
||||
|
||||
const val V0_2: String = "v0.2"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.shahondin1624.model.migration
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Interface for a single version migration step.
|
||||
* Each implementation transforms JSON from one schema version to the next.
|
||||
*/
|
||||
interface VersionMigration {
|
||||
/** The version this migration upgrades from. */
|
||||
val fromVersion: String
|
||||
|
||||
/** The version this migration upgrades to. */
|
||||
val toVersion: String
|
||||
|
||||
/**
|
||||
* Transform the JSON object from [fromVersion] schema to [toVersion] schema.
|
||||
* The returned JsonObject should have all version fields updated to [toVersion].
|
||||
*/
|
||||
fun migrate(json: JsonObject): JsonObject
|
||||
}
|
||||
@@ -2,11 +2,12 @@ package org.shahondin1624.model.talents
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.shahondin1624.model.Versionable
|
||||
import org.shahondin1624.model.migration.SchemaVersion
|
||||
|
||||
@Serializable
|
||||
data class Talents(
|
||||
val talents: List<TalentDefinition> = createAllProvidedTalents(),
|
||||
override val version: String = "v0.1"
|
||||
override val version: String = SchemaVersion.CURRENT
|
||||
) : Versionable {
|
||||
/**
|
||||
* Returns a copy with the specified talent's value updated.
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package org.shahondin1624
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.shahondin1624.lib.functions.DataLoader
|
||||
import org.shahondin1624.model.attributes.Attribute
|
||||
import org.shahondin1624.model.attributes.AttributeType
|
||||
import org.shahondin1624.model.attributes.Attributes
|
||||
import org.shahondin1624.model.characterdata.CharacterData
|
||||
import org.shahondin1624.model.characterdata.Metatype
|
||||
import org.shahondin1624.model.charactermodel.DamageMonitor
|
||||
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
|
||||
import org.shahondin1624.model.migration.MigrationRegistry
|
||||
import org.shahondin1624.model.migration.MigrationV01ToV02
|
||||
import org.shahondin1624.model.migration.SchemaVersion
|
||||
import org.shahondin1624.model.talents.Talent
|
||||
import org.shahondin1624.model.talents.Talents
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class VersionMigrationTest {
|
||||
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
// -- Helper to build a minimal v0.1 character JSON string --
|
||||
|
||||
private fun buildV01CharacterJson(
|
||||
includeNotes: Boolean = false,
|
||||
includeLifestyles: Boolean = false,
|
||||
extraFields: Map<String, String> = emptyMap()
|
||||
): String {
|
||||
val notesField = if (includeNotes) "\"notes\": \"Some notes\"," else ""
|
||||
val lifestylesField = if (includeLifestyles) "\"lifestyles\": []," else ""
|
||||
val extra = extraFields.entries.joinToString(",") { "\"${it.key}\": ${it.value}" }
|
||||
val extraComma = if (extra.isNotEmpty()) ",$extra" else ""
|
||||
|
||||
return """
|
||||
{
|
||||
"characterData": {
|
||||
"concept": "Test",
|
||||
"nuyen": 1000,
|
||||
"essence": 6.0,
|
||||
"name": "Test Character",
|
||||
"metatype": "Human",
|
||||
"age": 25,
|
||||
"gender": "male",
|
||||
"streetCred": 0,
|
||||
"notoriety": 0,
|
||||
"publicAwareness": 0,
|
||||
"totalKarma": 0,
|
||||
"currentKarma": 0,
|
||||
"version": "v0.1"
|
||||
},
|
||||
"attributes": {
|
||||
"body": { "type": "Body", "value": 3 },
|
||||
"agility": { "type": "Agility", "value": 3 },
|
||||
"reaction": { "type": "Reaction", "value": 3 },
|
||||
"strength": { "type": "Strength", "value": 3 },
|
||||
"willpower": { "type": "Willpower", "value": 3 },
|
||||
"logic": { "type": "Logic", "value": 3 },
|
||||
"intuition": { "type": "Intuition", "value": 3 },
|
||||
"charisma": { "type": "Charisma", "value": 3 },
|
||||
"edge": 2,
|
||||
"version": "v0.1"
|
||||
},
|
||||
"talents": {
|
||||
"talents": [
|
||||
{
|
||||
"type": "provided",
|
||||
"name": "Firearms",
|
||||
"attribute": "Agility",
|
||||
"value": 3,
|
||||
"custom": false
|
||||
}
|
||||
],
|
||||
"version": "v0.1"
|
||||
},
|
||||
"damageMonitor": {
|
||||
"stunCurrent": 0,
|
||||
"physicalCurrent": 0,
|
||||
"overflowCurrent": 0,
|
||||
"version": "v0.1"
|
||||
},
|
||||
$notesField
|
||||
$lifestylesField
|
||||
"version": "v0.1"
|
||||
$extraComma
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
// ---- MigrationRegistry Tests ----
|
||||
|
||||
@Test
|
||||
fun extractVersionReturnsVersionFromJson() {
|
||||
val jsonObj = json.parseToJsonElement("""{"version": "v0.1", "name": "test"}""").jsonObject
|
||||
assertEquals("v0.1", MigrationRegistry.extractVersion(jsonObj))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractVersionReturnsCurrentWhenNoVersionField() {
|
||||
val jsonObj = json.parseToJsonElement("""{"name": "test"}""").jsonObject
|
||||
assertEquals(SchemaVersion.CURRENT, MigrationRegistry.extractVersion(jsonObj))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun needsMigrationReturnsTrueForOldVersion() {
|
||||
assertTrue(MigrationRegistry.needsMigration("v0.1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun needsMigrationReturnsFalseForCurrentVersion() {
|
||||
assertFalse(MigrationRegistry.needsMigration(SchemaVersion.CURRENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findMigrationChainReturnsEmptyForCurrentVersion() {
|
||||
val chain = MigrationRegistry.findMigrationChain(SchemaVersion.CURRENT)
|
||||
assertTrue(chain.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findMigrationChainReturnsPathFromV01() {
|
||||
val chain = MigrationRegistry.findMigrationChain("v0.1")
|
||||
assertEquals(1, chain.size)
|
||||
assertEquals("v0.1", chain[0].fromVersion)
|
||||
assertEquals("v0.2", chain[0].toVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findMigrationChainThrowsForUnknownVersion() {
|
||||
assertFailsWith<IllegalStateException> {
|
||||
MigrationRegistry.findMigrationChain("v99.99")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- MigrationV01ToV02 Tests ----
|
||||
|
||||
@Test
|
||||
fun migrationV01ToV02UpdatesVersionFields() {
|
||||
val input = json.parseToJsonElement(buildV01CharacterJson()).jsonObject
|
||||
val migration = MigrationV01ToV02()
|
||||
val result = migration.migrate(input)
|
||||
|
||||
assertEquals("v0.2", result["version"]?.jsonPrimitive?.content)
|
||||
assertEquals("v0.2", result["characterData"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
|
||||
assertEquals("v0.2", result["attributes"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
|
||||
assertEquals("v0.2", result["talents"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
|
||||
assertEquals("v0.2", result["damageMonitor"]?.jsonObject?.get("version")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationV01ToV02AddsNotesFieldWhenMissing() {
|
||||
val input = json.parseToJsonElement(buildV01CharacterJson(includeNotes = false)).jsonObject
|
||||
val migration = MigrationV01ToV02()
|
||||
val result = migration.migrate(input)
|
||||
|
||||
assertEquals("", result["notes"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationV01ToV02PreservesExistingNotes() {
|
||||
val input = json.parseToJsonElement(buildV01CharacterJson(includeNotes = true)).jsonObject
|
||||
val migration = MigrationV01ToV02()
|
||||
val result = migration.migrate(input)
|
||||
|
||||
assertEquals("Some notes", result["notes"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationV01ToV02AddsLifestylesFieldWhenMissing() {
|
||||
val input = json.parseToJsonElement(buildV01CharacterJson(includeLifestyles = false)).jsonObject
|
||||
val migration = MigrationV01ToV02()
|
||||
val result = migration.migrate(input)
|
||||
|
||||
assertTrue(result.containsKey("lifestyles"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationV01ToV02PreservesExistingLifestyles() {
|
||||
val input = json.parseToJsonElement(buildV01CharacterJson(includeLifestyles = true)).jsonObject
|
||||
val migration = MigrationV01ToV02()
|
||||
val result = migration.migrate(input)
|
||||
|
||||
assertTrue(result.containsKey("lifestyles"))
|
||||
}
|
||||
|
||||
// ---- DataLoader Integration Tests ----
|
||||
|
||||
@Test
|
||||
fun deserializeV01JsonMigratesToCurrentVersion() {
|
||||
val v01Json = buildV01CharacterJson()
|
||||
val character = DataLoader.deserialize(v01Json)
|
||||
|
||||
assertEquals(SchemaVersion.CURRENT, character.version)
|
||||
assertEquals(SchemaVersion.CURRENT, character.characterData.version)
|
||||
assertEquals(SchemaVersion.CURRENT, character.attributes.version)
|
||||
assertEquals(SchemaVersion.CURRENT, character.talents.version)
|
||||
assertEquals("Test Character", character.characterData.name)
|
||||
assertEquals(Metatype.Human, character.characterData.metatype)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeV01JsonWithoutNotesDefaultsToEmpty() {
|
||||
val v01Json = buildV01CharacterJson(includeNotes = false)
|
||||
val character = DataLoader.deserialize(v01Json)
|
||||
|
||||
assertEquals("", character.notes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeV01JsonWithoutLifestylesDefaultsToEmpty() {
|
||||
val v01Json = buildV01CharacterJson(includeLifestyles = false)
|
||||
val character = DataLoader.deserialize(v01Json)
|
||||
|
||||
assertEquals(emptyList(), character.lifestyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deserializeCurrentVersionJsonSkipsMigration() {
|
||||
// Create a character, serialize it (will be at current version), then deserialize
|
||||
val attributes = Attributes(
|
||||
body = Attribute(AttributeType.Body, 3),
|
||||
agility = Attribute(AttributeType.Agility, 3),
|
||||
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
|
||||
)
|
||||
val original = ShadowrunCharacter(
|
||||
characterData = CharacterData(
|
||||
concept = "Test", nuyen = 1000, essence = 6.0f, name = "Test",
|
||||
metatype = Metatype.Human, age = 25, gender = "male",
|
||||
streetCred = 0, notoriety = 0, publicAwareness = 0,
|
||||
totalKarma = 0, currentKarma = 0
|
||||
),
|
||||
attributes = attributes,
|
||||
talents = Talents(
|
||||
talents = listOf(
|
||||
Talent(name = "Test", attribute = AttributeType.Body, value = 1, custom = false)
|
||||
)
|
||||
),
|
||||
damageMonitor = DamageMonitor(attributes = attributes),
|
||||
notes = "Some notes"
|
||||
)
|
||||
|
||||
val serialized = DataLoader.serialize(original)
|
||||
val restored = DataLoader.deserialize(serialized)
|
||||
|
||||
assertEquals(SchemaVersion.CURRENT, restored.version)
|
||||
assertEquals("Some notes", restored.notes)
|
||||
assertEquals("Test", restored.characterData.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownFieldsInJsonAreIgnored() {
|
||||
// Add an unknown field to the JSON - should not crash
|
||||
val v01Json = buildV01CharacterJson(
|
||||
extraFields = mapOf("unknownField" to "\"some value\"", "anotherUnknown" to "42")
|
||||
)
|
||||
val character = DataLoader.deserialize(v01Json)
|
||||
|
||||
// Should deserialize successfully, ignoring unknown fields
|
||||
assertEquals("Test Character", character.characterData.name)
|
||||
assertEquals(SchemaVersion.CURRENT, character.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOptionalFieldsUseSensibleDefaults() {
|
||||
// v0.1 JSON without notes and lifestyles - should use defaults
|
||||
val v01Json = buildV01CharacterJson(includeNotes = false, includeLifestyles = false)
|
||||
val character = DataLoader.deserialize(v01Json)
|
||||
|
||||
assertEquals("", character.notes, "Missing notes should default to empty string")
|
||||
assertEquals(emptyList(), character.lifestyles, "Missing lifestyles should default to empty list")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionIsCheckedOnLoadAndMigrationApplied() {
|
||||
// Verify that v0.1 data gets migrated to current
|
||||
val v01Json = buildV01CharacterJson()
|
||||
val character = DataLoader.deserialize(v01Json)
|
||||
assertEquals(SchemaVersion.CURRENT, character.version)
|
||||
|
||||
// Verify that re-serializing produces current version
|
||||
val reSerialized = DataLoader.serialize(character)
|
||||
val reLoaded = DataLoader.deserialize(reSerialized)
|
||||
assertEquals(SchemaVersion.CURRENT, reLoaded.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrationChainAppliesSequentially() {
|
||||
// With current setup there's one migration (v0.1 -> v0.2)
|
||||
// Verify the full chain works through MigrationRegistry
|
||||
val input = json.parseToJsonElement(buildV01CharacterJson()).jsonObject
|
||||
val migrated = MigrationRegistry.migrateToCurrentVersion(input)
|
||||
|
||||
assertEquals(SchemaVersion.CURRENT, migrated["version"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentVersionJsonNeedNoMigration() {
|
||||
val currentJson = JsonObject(mapOf("version" to JsonPrimitive(SchemaVersion.CURRENT)))
|
||||
val result = MigrationRegistry.migrateToCurrentVersion(currentJson)
|
||||
// Should return the same object since no migration is needed
|
||||
assertEquals(SchemaVersion.CURRENT, result["version"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidVersionThrowsMeaningfulError() {
|
||||
val badJson = json.parseToJsonElement("""{"version": "v99.99"}""").jsonObject
|
||||
val exception = assertFailsWith<IllegalStateException> {
|
||||
MigrationRegistry.migrateToCurrentVersion(badJson)
|
||||
}
|
||||
assertTrue(exception.message!!.contains("v99.99"))
|
||||
assertTrue(exception.message!!.contains("No migration path"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user