package com.craigvg.lichun_android.network import com.craigvg.lichun_android.network.dto.AchievementDto import com.craigvg.lichun_android.network.dto.AccountDeletionScheduledDto import com.craigvg.lichun_android.network.dto.ConversationEventDto import com.craigvg.lichun_android.network.dto.DailyRewardStatusDto import com.craigvg.lichun_android.network.dto.EnergyRefillTiersDto import com.craigvg.lichun_android.network.dto.ErrorMessageDto import com.craigvg.lichun_android.network.dto.LightweightUpdateDto import com.craigvg.lichun_android.network.dto.MessageEventDto import com.craigvg.lichun_android.network.dto.PurchaseCompleteDto import com.craigvg.lichun_android.network.dto.QuestionEventDto import com.craigvg.lichun_android.network.dto.RelationshipEventDto import com.craigvg.lichun_android.network.dto.TimeSkipSummaryDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Verifies the lenient `@Serializable` DTOs decode server payloads the same way * the old hand-rolled extraction did: tolerating missing fields with the same * defaults, and ignoring unmodeled extra fields without throwing. */ class InboundMessageDtoTest { private val json = Json { ignoreUnknownKeys = true isLenient = true coerceInputValues = true } private inline fun decode(text: String): T = json.decodeFromJsonElement(json.parseToJsonElement(text).jsonObject) @Test fun `lightweight update decodes present fields and leaves absent ones null`() { val dto = decode( """{"type":"u","hourOfDay":14,"money":250.5,"diamonds":7}""" ) assertEquals(14, dto.hourOfDay) assertEquals(250.5, dto.money!!, 0.0001) assertEquals(7, dto.diamonds) assertNull(dto.minuteOfHour) assertNull(dto.date) assertNull(dto.mood) } @Test fun `messageEvent tolerates missing fields with defaults and ignores unknown keys`() { val dto = decode( """{"type":"messageEvent","message":"Hi","unknownField":123,"energyCost":5}""" ) assertEquals("Hi", dto.message) assertEquals("messageEvent", dto.type) assertEquals(5, dto.energyCost) assertNull(dto.id) assertNull(dto.moneyCost) assertEquals(0, dto.hour) } @Test fun `questionEvent decodes nested answer options`() { val dto = decode( """{"type":"questionEvent","id":"q1","message":"Pick","answers":[ {"option":"A","id":"c1","energyCost":3}, {"option":"B","moneyCost":10.0} ]}""" ) assertEquals("q1", dto.id) assertEquals(2, dto.answers.size) assertEquals("A", dto.answers[0].option) assertEquals("c1", dto.answers[0].id) assertEquals(3, dto.answers[0].energyCost) assertEquals(10.0, dto.answers[1].moneyCost!!, 0.0001) assertNull(dto.answers[1].id) } @Test fun `conversationEvent decodes message list with tolerant defaults`() { val dto = decode( """{"type":"conversationEvent","id":"conv1","character":"Alex","conversation":[ {"id":"m1","message":"hey"} ],"question":1}""" ) assertEquals("conv1", dto.id) assertEquals("Alex", dto.character) assertEquals(1, dto.question) assertEquals(1, dto.conversation.size) assertEquals("hey", dto.conversation[0].message) // datetime/date/time absent -> default "" (matches old `?: ""`) assertEquals("", dto.conversation[0].datetime) assertEquals("", dto.conversation[0].sentiment) } @Test fun `error message maps snake_case error_code`() { val dto = decode( """{"type":"error","message":"Nope","error_code":"INSUFFICIENT_ENERGY","required":50,"available":10}""" ) assertEquals("Nope", dto.message) assertEquals("INSUFFICIENT_ENERGY", dto.errorCode) assertEquals(50, dto.required) assertEquals(10, dto.available) } @Test fun `energy refill tiers decode as keyed map`() { val dto = decode( """{"type":"energyRefillTiers","tiers":{ "small":{"energy":50,"diamonds":5}, "full":{"energy":200,"diamonds":20} }}""" ) assertEquals(2, dto.tiers.size) assertEquals(50, dto.tiers["small"]?.energy) assertEquals(20, dto.tiers["full"]?.diamonds) } @Test fun `purchaseComplete decodes nested newBalance`() { val dto = decode( """{"type":"purchaseComplete","newBalance":{"diamonds":99,"unlimitedUntil":"2026-01-01T00:00:00Z"}}""" ) assertNull(dto.itemId) assertEquals(99, dto.newBalance?.diamonds) assertEquals("2026-01-01T00:00:00Z", dto.newBalance?.unlimitedUntil) } @Test fun `timeSkipSummary maps snake_case event fields`() { val dto = decode( """{"diamonds":3,"newTime":"Day 2","durationHours":24.0,"events":[ {"type":"work","description":"earned","money_earned":120.0,"smarts_gained":2} ],"statChanges":{"money":120.0,"energy":-10,"health":0,"happiness":5}}""" ) assertEquals(3, dto.diamonds) assertEquals(1, dto.events.size) assertEquals(120.0, dto.events[0].moneyEarned!!, 0.0001) assertEquals(2, dto.events[0].smartsGained) assertEquals(-10, dto.statChanges?.energy) } @Test fun `achievement keeps raw category string for fallback resolution`() { // "SOCIAL" is not a valid AchievementCategory; old code fell back to CAREER. // The DTO keeps it as a raw String so the manager can apply that fallback. val dto = decode( """{"id":"a1","name":"Test","category":"SOCIAL","reward":10}""" ) assertEquals("SOCIAL", dto.category) assertEquals("a1", dto.id) assertEquals(10, dto.reward) assertTrue(!dto.unlocked) } @Test fun `dayReward exposes both id and day for id fallback`() { val dto = decode( """{"type":"dailyRewardStatus","rewards":[{"day":3,"diamonds":15}]}""" ) assertEquals(1, dto.rewards.size) assertNull(dto.rewards[0].id) assertEquals(3, dto.rewards[0].day) assertEquals(15, dto.rewards[0].diamonds) } @Test fun `relationshipEvent defaults eventType to ARGUMENT when absent`() { val dto = decode( """{"type":"relationshipEvent","id":"re1","title":"Fight","choices":[ {"id":"c1","text":"Apologize","affinityChange":5} ]}""" ) assertEquals("re1", dto.id) assertEquals("ARGUMENT", dto.eventType) assertEquals(1, dto.choices.size) assertEquals(5, dto.choices[0].affinityChange) assertEquals(0, dto.choices[0].energyCost) } @Test fun `accountDeletionScheduled reads nested result success`() { val dto = decode( """{"type":"accountDeletionScheduled","result":{"success":true,"scheduledAt":"soon"},"gracePeriodDays":30}""" ) assertEquals(true, dto.result?.success) assertEquals("soon", dto.result?.scheduledAt) assertEquals(30, dto.gracePeriodDays) } }