package com.craigvg.lichun_android.network import android.content.Context import androidx.test.core.app.ApplicationProvider import app.cash.turbine.test import com.craigvg.lichun_android.domain.models.AchievementCategory import com.craigvg.lichun_android.domain.models.QuestCategory import com.craigvg.lichun_android.managers.ToastManager import com.craigvg.lichun_android.managers.ToastType import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** * Drives representative server -> client JSON through [WebSocketManager]'s private * `handleMessage` dispatch (reached via reflection, since the WebSocket transport * is the only public entry point) and asserts the resulting StateFlow values. * * This exercises a broad slice of the `when(type)` surface end-to-end: the JSON is * parsed by the manager's real lenient [kotlinx.serialization] config, decoded into * the `@Serializable` DTOs, and projected onto domain state — the same path a live * socket frame takes. A real [ToastManager] is used so action/debug confirmations * can be asserted on its toast StateFlow. */ @RunWith(RobolectricTestRunner::class) @Config(manifest = Config.NONE) class WebSocketManagerDispatchTest { private lateinit var toastManager: ToastManager private lateinit var manager: WebSocketManager @Before fun setUp() { val context = ApplicationProvider.getApplicationContext() toastManager = ToastManager() manager = WebSocketManager(context, toastManager) } /** Invoke the private dispatch entry point with a raw JSON frame. */ private fun deliver(json: String) { val method = WebSocketManager::class.java .getDeclaredMethod("handleMessage", String::class.java) .apply { isAccessible = true } method.invoke(manager, json) } // ---- u (lightweight update) -------------------------------------------- @Test fun `lightweight update mutates player and person scalars`() { deliver( """{"type":"u","hourOfDay":14,"minuteOfHour":30,"gameSpeed":5, "money":250.5,"diamonds":7,"calcEnergy":80,"location":"Home", "status":"working","mood":"happy"}""" ) val player = manager.player.value assertEquals(14, player.hourOfDay) assertEquals(30, player.minuteOfHour) assertEquals(5, player.gameSpeed) val person = manager.person.value assertEquals(250.5, person.money, 0.0001) assertEquals(7, person.diamonds) assertEquals(80, person.calcEnergy) assertEquals("Home", person.location) assertEquals("working", person.status) assertEquals("happy", person.mood) } @Test fun `lightweight update leaves untouched fields at prior values`() { deliver("""{"type":"u","diamonds":3}""") deliver("""{"type":"u","money":99.0}""") // Second frame had no diamonds; the value from the first frame persists. assertEquals(3, manager.person.value.diamonds) assertEquals(99.0, manager.person.value.money, 0.0001) } // ---- playerObject ------------------------------------------------------ @Test fun `playerObject populates scalars relationships and character`() { deliver( """{"type":"playerObject","date":"2026-05-01","gameSpeed":2,"status":"alive", "season":"Spring","hourOfDay":9,"minuteOfHour":15, "r":[{"id":"npc1","firstname":"Alex","affinity":40}], "c":{"id":"me","firstname":"Bao","lastname":"Li","ageYears":18,"diamonds":12}}""" ) val player = manager.player.value assertEquals("2026-05-01", player.date) assertEquals(2, player.gameSpeed) assertEquals("Spring", player.season) assertEquals(9, player.hourOfDay) assertEquals(1, player.r.size) assertEquals("Alex", player.r[0].firstname) assertEquals(40, player.r[0].affinity) val person = manager.person.value assertEquals("me", person.id) assertEquals("Bao", person.firstname) assertEquals(18, person.ageYears) assertEquals(12, person.diamonds) } @Test fun `playerObject death surfaces on person status not top-level player status`() { // The server keeps the TOP-LEVEL status at "playing" even after the // character dies; only the character blob `c.status` flips to "dead". // Death routing must key off person.status, so verify the split here. deliver( """{"type":"playerObject","date":"2026-05-01","gameSpeed":2,"status":"playing", "season":"Spring","hourOfDay":9,"minuteOfHour":15, "c":{"id":"me","firstname":"Bao","status":"dead","ageYears":78}}""" ) assertEquals("playing", manager.player.value.status) assertEquals("dead", manager.person.value.status) } // ---- lifeSummaryEvent -------------------------------------------------- @Test fun `lifeSummaryEvent stores recap and flips person status to dead for mid-session death`() { deliver( """{"type":"lifeSummaryEvent","summary":{"finalAge":81,"netWorth":120000.0, "score":7400,"diedAt":"2026-05-01"}}""" ) // Recap is stored for the death screen / New Life chooser. val summary = manager.lifeSummary.value assertNotNull(summary) assertEquals(81, summary?.finalAge) assertEquals(7400, summary?.score) // Mid-session death has no fresh playerObject, so the handler itself must // flip person.status to "dead" so navigation routes to the death screen. assertEquals("dead", manager.person.value.status) } // ---- personObject ------------------------------------------------------ @Test fun `personObject parses into person state`() { deliver( """{"type":"personObject","id":"p9","firstname":"Mei","lastname":"Chen", "ageYears":25,"affinity":60,"money":1000.0,"diamonds":5}""" ) val person = manager.person.value assertEquals("p9", person.id) assertEquals("Mei", person.firstname) assertEquals(25, person.ageYears) assertEquals(60, person.affinity) assertEquals(1000.0, person.money, 0.0001) } // ---- messageEvent ------------------------------------------------------ @Test fun `messageEvent without image is appended to life events`() { deliver( """{"type":"messageEvent","id":"ev1","message":"You got promoted at work!", "title":"Promotion"}""" ) val events = manager.lifeEvents.value assertEquals(1, events.size) assertEquals("ev1", events[0].id) assertEquals("You got promoted at work!", events[0].message) // No image => not surfaced as a modal. assertNull(manager.currentMessageEvent.value) } @Test fun `messageEvent with image is surfaced as modal not list`() { deliver( """{"type":"messageEvent","id":"ev2","message":"A wild event!","image":"event.png"}""" ) assertNotNull(manager.currentMessageEvent.value) assertEquals("ev2", manager.currentMessageEvent.value?.id) assertTrue(manager.lifeEvents.value.isEmpty()) } // ---- questionEvent ----------------------------------------------------- @Test fun `questionEvent enqueues and sets current question`() { deliver( """{"type":"questionEvent","id":"q1","message":"Coffee or tea?","answers":[ {"option":"Coffee","id":"c1"},{"option":"Tea","id":"c2"}]}""" ) assertEquals(1, manager.questionQueue.value.size) assertEquals("q1", manager.currentQuestion.value?.id) assertEquals(2, manager.currentQuestion.value?.answers?.size) assertEquals("Coffee or tea?", manager.currentQuestion.value?.question) } // ---- relationshipEvent ------------------------------------------------- @Test fun `relationshipEvent decodes choices and defaults unknown type to ARGUMENT`() { deliver( """{"type":"relationshipEvent","id":"re1","eventType":"NOT_A_TYPE", "title":"Tension","description":"Things are tense","partnerName":"Sam", "partnerId":"sam1","choices":[{"id":"a","text":"Apologize","affinityChange":5}]}""" ) val event = manager.currentRelationshipEvent.value assertNotNull(event) assertEquals("re1", event?.id) assertEquals("Sam", event?.partnerName) assertEquals(1, event?.choices?.size) assertEquals(5, event?.choices?.first()?.affinityChange) } // ---- error ------------------------------------------------------------- @Test fun `error with insufficient resource code maps to InsufficientResources`() { deliver( """{"type":"error","message":"Need more","error_code":"INSUFFICIENT_DIAMONDS", "required":50,"available":10}""" ) val err = manager.currentError.value assertTrue(err is WebSocketError.InsufficientResources) err as WebSocketError.InsufficientResources assertEquals("diamonds", err.resource) assertEquals(50, err.required) assertEquals(10, err.available) } @Test fun `error without code shows toast and sets server error`() = runTest { deliver("""{"type":"error","message":"Something broke"}""") assertTrue(manager.currentError.value is WebSocketError.ServerError) assertEquals("Something broke", manager.currentError.value?.userMessage) assertEquals(ToastType.ERROR, toastManager.currentToast.value?.type) assertEquals("Something broke", toastManager.currentToast.value?.message) } // ---- account ----------------------------------------------------------- @Test fun `accountDeletionScheduled records grace period`() { deliver( """{"type":"accountDeletionScheduled","result":{"success":true,"scheduledAt":"2026-06-01"}, "gracePeriodDays":30}""" ) val update = manager.accountDeletionUpdate.value assertNotNull(update) assertTrue(update!!.scheduled) assertEquals("2026-06-01", update.scheduledAt) assertEquals(30, update.gracePeriodDays) } @Test fun `accountDeletionCancelled clears scheduled flag`() { deliver("""{"type":"accountDeletionCancelled","success":true,"message":"Cancelled."}""") val update = manager.accountDeletionUpdate.value assertNotNull(update) assertFalse(update!!.scheduled) assertEquals("Cancelled.", update.message) } @Test fun `dataExportComplete captures payload`() { deliver("""{"type":"dataExportComplete","exportData":"{\"user\":\"bao\"}"}""") assertNotNull(manager.dataExportPayload.value) } // ---- monetization: tiers + purchaseComplete + timeSkipComplete --------- @Test fun `energyRefillTiers decode sorted by diamond cost and open modal`() { deliver( """{"type":"energyRefillTiers","tiers":{ "full":{"energy":200,"diamonds":20}, "small":{"energy":50,"diamonds":5}}}""" ) val tiers = manager.energyRefillTiers.value assertEquals(2, tiers.size) // Sorted ascending by diamonds. assertEquals("small", tiers[0].type) assertEquals(5, tiers[0].diamonds) assertEquals("full", tiers[1].type) assertTrue(manager.showEnergyRefillModal.value) } @Test fun `timeSkipTiers decode sorted by diamond cost and open modal`() { deliver( """{"type":"timeSkipTiers","tiers":{ "1week":{"durationSeconds":604800.0,"diamonds":30}, "1hour":{"durationSeconds":3600.0,"diamonds":3}}}""" ) val tiers = manager.timeSkipTiers.value assertEquals(2, tiers.size) assertEquals("1hour", tiers[0].type) assertEquals(3, tiers[0].diamonds) assertTrue(manager.showTimeSkipModal.value) } @Test fun `purchaseComplete with itemId records last store purchase`() { deliver("""{"type":"purchaseComplete","itemId":"hat_01","success":true}""") assertEquals("hat_01", manager.lastStorePurchaseItemId.value) } @Test fun `purchaseComplete with newBalance updates person resources and closes modals`() { manager.setShowEnergyRefillModal(true) deliver( """{"type":"purchaseComplete","newBalance":{"diamonds":99,"energy":100,"money":500.0}}""" ) assertEquals(99, manager.person.value.diamonds) assertEquals(100, manager.person.value.calcEnergy) assertEquals(500.0, manager.person.value.money, 0.0001) assertFalse(manager.showEnergyRefillModal.value) } @Test fun `timeSkipComplete builds summary and toggles UI flags`() { deliver( """{"type":"timeSkipComplete","summary":{"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}}}""" ) val summary = manager.lastTimeSkipSummary.value assertNotNull(summary) assertEquals(3, summary?.diamonds) assertEquals("Day 2", summary?.newTime) assertEquals(1, summary?.events?.size) assertEquals(-10, summary?.statChanges?.energy) assertFalse(manager.showTimeSkipModal.value) assertTrue(manager.showTimeSkipSummary.value) } // ---- retention: achievements, daily rewards, quests -------------------- @Test fun `achievementsList decodes with category fallback`() { deliver( """{"type":"achievementsList","achievements":[ {"id":"a1","name":"First Job","category":"CAREER","reward":10}, {"id":"a2","name":"Mystery","category":"UNKNOWN_CAT","reward":5}]}""" ) val achievements = manager.achievements.value assertEquals(2, achievements.size) assertEquals(AchievementCategory.CAREER, achievements[0].category) // Unknown category falls back to CAREER per manager logic. assertEquals(AchievementCategory.CAREER, achievements[1].category) } @Test fun `achievementUnlocked queues unacknowledged and toggles unlock flag`() { deliver( """{"type":"achievementUnlocked","achievement":{"id":"a3","name":"Rich", "category":"WEALTH","reward":25}}""" ) assertEquals(1, manager.unacknowledgedAchievements.value.size) assertEquals("a3", manager.unacknowledgedAchievements.value[0].id) assertTrue(manager.unacknowledgedAchievements.value[0].unlocked) assertTrue(manager.showAchievementUnlock.value) } @Test fun `dailyRewardStatus then dailyRewardClaimed marks day claimed and grants diamonds`() { deliver( """{"type":"dailyRewardStatus","currentStreak":2,"canClaim":true, "rewards":[{"day":1,"diamonds":5,"claimed":true},{"day":2,"diamonds":10}]}""" ) assertEquals(2, manager.dailyRewardState.value?.currentStreak) assertEquals(2, manager.dailyRewardState.value?.rewards?.size) deliver( """{"type":"dailyRewardClaimed","success":true,"day":2,"reward":{"diamonds":10}}""" ) val state = manager.dailyRewardState.value assertTrue(state!!.todaysClaimed) assertTrue(state.rewards.first { it.id == 2 }.claimed) assertEquals(10, manager.person.value.diamonds) } @Test fun `dailyRewardClaimed with success false is a no-op`() { deliver("""{"type":"dailyRewardClaimed","success":false,"day":1}""") assertNull(manager.dailyRewardState.value) assertEquals(0, manager.person.value.diamonds) } @Test fun `dailyQuestsStatus decodes quests with category fallback`() { deliver( """{"type":"dailyQuestsStatus","quests":[ {"id":"qst1","name":"Make a friend","category":"SOCIAL","target":1, "reward":{"diamonds":5}}, {"id":"qst2","name":"Odd","category":"BOGUS","target":3,"reward":{"diamonds":2}}]}""" ) val state = manager.dailyQuestsState.value assertNotNull(state) assertEquals(2, state?.quests?.size) assertEquals(QuestCategory.SOCIAL, state?.quests?.get(0)?.category) // Unknown quest category falls back to SOCIAL. assertEquals(QuestCategory.SOCIAL, state?.quests?.get(1)?.category) } @Test fun `questProgress updates a tracked quest progress`() { deliver( """{"type":"dailyQuestsStatus","quests":[ {"id":"qst1","name":"Walk","category":"ACTIVITIES","progress":0,"target":5, "reward":{"diamonds":5}}]}""" ) deliver("""{"type":"questProgress","quest":{"id":"qst1","progress":3,"completed":false}}""") val quest = manager.dailyQuestsState.value?.quests?.first { it.id == "qst1" } assertEquals(3, quest?.progress) } @Test fun `questRewardClaimed marks quest claimed and grants reward`() { deliver( """{"type":"dailyQuestsStatus","quests":[ {"id":"qst1","name":"Walk","category":"ACTIVITIES","progress":5,"target":5, "completed":true,"reward":{"diamonds":5}}]}""" ) deliver( """{"type":"questRewardClaimed","success":true,"questId":"qst1","reward":{"diamonds":5}}""" ) val quest = manager.dailyQuestsState.value?.quests?.first { it.id == "qst1" } assertTrue(quest!!.claimed) assertEquals(5, manager.person.value.diamonds) } // ---- dating ------------------------------------------------------------ @Test fun `getSwipeCharacter populates swipe character`() { deliver( """{"type":"getSwipeCharacter","swipeCharacter":{"id":"swipe1","firstname":"Jordan", "ageYears":27}}""" ) val swipe = manager.swipeCharacter.value assertNotNull(swipe) assertEquals("swipe1", swipe?.id) assertEquals("Jordan", swipe?.firstname) } @Test fun `dateIdeas decode and drop entries without a name`() { deliver( """{"type":"dateIdeas","dateIdeas":[ {"name":"Picnic","energy_cost":10,"money_cost":20.0}, {"energy_cost":5}]}""" ) val ideas = manager.dateIdeas.value assertEquals(1, ideas.size) assertEquals("Picnic", ideas[0].name) } // ---- extracurriculars + tutorial --------------------------------------- @Test fun `extraCurriculars decode into list`() { deliver( """{"type":"extraCurriculars","extraCurriculars":[ {"id":"e1","title":"Chess Club","type":"club","description":"Play chess"}]}""" ) assertEquals(1, manager.extracurriculars.value.size) assertEquals("Chess Club", manager.extracurriculars.value[0].title) } @Test fun `tutorial step update and onboarding complete flow`() { deliver("""{"type":"tutorialStepUpdated","step":3}""") assertEquals(3, manager.tutorialStep.value) deliver("""{"type":"onboardingComplete"}""") assertTrue(manager.tutorialComplete.value) deliver("""{"type":"tutorial_message","message":"Welcome!"}""") assertEquals("Welcome!", manager.tutorialMessage.value) } // ---- action / debug confirmations (toast assertions) ------------------- @Test fun `action confirmation shows success toast with default message`() { deliver("""{"type":"jobApplied","success":true}""") assertEquals(ToastType.SUCCESS, toastManager.currentToast.value?.type) assertEquals("Job application submitted.", toastManager.currentToast.value?.message) } @Test fun `action confirmation with explicit message uses it`() { deliver("""{"type":"romanceStarted","success":true,"message":"You and Sam are now dating!"}""") assertEquals("You and Sam are now dating!", toastManager.currentToast.value?.message) } @Test fun `debug action complete shows toast`() { deliver("""{"type":"debugGrantComplete","success":true}""") assertEquals(ToastType.SUCCESS, toastManager.currentToast.value?.type) assertEquals("Debug resources applied.", toastManager.currentToast.value?.message) } // ---- malformed / unknown ---------------------------------------------- @Test fun `malformed json does not throw and leaves state untouched`() { deliver("this is not json") deliver("""{"no":"type field"}""") deliver("""{"type":"totallyUnknownType","foo":1}""") // No state corrupted, no crash. assertEquals(0, manager.person.value.diamonds) assertTrue(manager.lifeEvents.value.isEmpty()) assertNull(manager.currentError.value) } // ---- turbine: StateFlow emission ordering ------------------------------ @Test fun `playerUpdate emits diamond change observable via turbine`() = runTest { // StateFlow conflates emissions, so deliver the update first, then assert // the collector observes the resulting value (latest wins). deliver("""{"type":"playerUpdate","diamonds":42,"energy":77}""") manager.person.test { val item = awaitItem() assertEquals(42, item.diamonds) assertEquals(77, item.calcEnergy) cancelAndIgnoreRemainingEvents() } } // ---- Tier 2 retention spine -------------------------------------------- @Test fun `lifeSummaryEvent decodes summary score legacy and heir`() { deliver( """{"type":"lifeSummaryEvent","summary":{ "finalAge":82,"netWorth":125000.0, "peakCareer":{"title":"Surgeon","bestIncome":300000.0}, "relationshipsCount":12,"childrenCount":3, "notableEvents":["Graduated college","Got married"], "achievementsEarned":7,"lifetimeEarnings":2400000.0,"score":1850, "diedAt":"2026-05-27T00:00:00Z", "legacy":{"heir":{"id":"kid1","name":"Sam Li","sex":"Male","ageYears":24,"affinity":80}, "inheritance":62500.0,"familyPrestige":12.0,"prestigeGained":4.0, "familyTree":[{"name":"Bao Li","sex":"Female","finalAge":82, "peakCareer":"Surgeon","score":1850,"netWorth":125000.0, "diedAt":"2026-05-27T00:00:00Z","generation":1}]}}}""" ) val summary = manager.lifeSummary.value assertNotNull(summary) assertEquals(82, summary!!.finalAge) assertEquals(1850, summary.score) assertEquals("Surgeon", summary.peakCareer?.title) assertEquals(3, summary.childrenCount) assertEquals(2, summary.notableEvents.size) assertEquals("Sam Li", summary.legacy?.heir?.name) assertEquals(62500.0, summary.legacy?.inheritance ?: 0.0, 0.0001) assertEquals(1, summary.legacy?.familyTree?.size) assertEquals(1, summary.legacy?.familyTree?.get(0)?.generation) } @Test fun `offlineDigest decodes minutes money age and notable events`() { deliver( """{"type":"offlineDigest","minutesAway":180,"moneyDelta":250.0, "ageYearsDelta":1,"notableEvents":["Got a raise","Made a new friend"], "generatedAt":"2026-05-27T08:00:00Z"}""" ) val digest = manager.offlineDigest.value assertNotNull(digest) assertEquals(180, digest!!.minutesAway) assertEquals(250.0, digest.moneyDelta, 0.0001) assertEquals(1, digest.ageYearsDelta) assertEquals(2, digest.notableEvents.size) assertEquals("2026-05-27T08:00:00Z", digest.generatedAt) } @Test fun `lifeGoalsUpdate decodes active completed score and justCompleted`() { deliver( """{"type":"lifeGoalsUpdate","lifeScore":420, "active":[{"id":"g1","title":"Earn 1M","description":"Bank a million","icon":"money", "target":1000000,"reward":50,"lifeScore":100,"current":250000,"progressPercent":25}], "completed":[{"id":"g0","completedAt":"2026-05-01","title":"First Job","icon":"work"}], "justCompleted":[{"id":"g0","title":"First Job","description":"Land a job","icon":"work","reward":20,"lifeScore":30}]}""" ) val goals = manager.lifeGoals.value assertNotNull(goals) assertEquals(420, goals!!.lifeScore) assertEquals(1, goals.active.size) assertEquals("Earn 1M", goals.active[0].title) assertEquals(25, goals.active[0].progressPercent) assertEquals(1, goals.completed.size) assertEquals("First Job", goals.completed[0].title) assertEquals(1, goals.justCompleted.size) } @Test fun `playerObject populates familyPrestige and familyTree`() { deliver( """{"type":"playerObject","date":"2026-05-01","status":"alive", "familyPrestige":16.0, "familyTree":[{"name":"Bao Li","sex":"Female","finalAge":80,"peakCareer":"Teacher", "score":900,"netWorth":50000.0,"diedAt":"2025-01-01","generation":1}], "c":{"id":"me","firstname":"Sam"}}""" ) val player = manager.player.value assertEquals(16.0, player.familyPrestige, 0.0001) assertEquals(1, player.familyTree.size) assertEquals("Bao Li", player.familyTree[0].name) assertEquals("Teacher", player.familyTree[0].peakCareer) } }