package com.sptmobile.endpoint import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test class TimelineTest { private fun turns(json: String): List = Json.parseToJsonElement(json).jsonObject["turns"]!!.jsonArray.map { it.jsonObject } private fun row( msgId: String?, tsMs: Long, dir: String = "out", from: String = "mobile-gw", body: String = "b", ) = HistoryRow(msg_id = msgId, ts_ms = tsMs, dir = dir, from = from, body = body) // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // History rows and digest rows merge into ONE timeline ordered by // timestamp; digest internal order is never reshuffled (seq order is the // truth) and ties resolve history-first, deterministically. @Test fun mergesHistoryAndDigestByTimestamp() { val digest = turns( """{"turns":[{"input":"q","input_seq":1,"entries":[ {"Agent":{"text":"early","seq":2,"ts":"1970-01-01T00:00:02Z"}}, {"Agent":{"text":"late","seq":3,"ts":"1970-01-01T00:00:08Z"}} ]}]}""" ) val history = listOf( row("m1", 1_000, dir = "in", body = "first"), row("m2", 5_000, dir = "out", body = "between"), ) val rows = Timeline.interlace(history, emptyList(), digest, emptySet()) val texts = rows.map { when (it) { is TimelineRow.Message -> it.body is TimelineRow.DigestEntry -> it.text is TimelineRow.DigestInput -> it.text } } // input has no own ts: backfilled from first known (2s) → after the // 1s history row. Then agent@2s, history@5s, agent@8s. assertEquals(listOf("first", "q", "early", "between", "late"), texts) } // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // Digest rows without a ts carry the previous row's ts forward; a ts-less // head backfills from the first known ts — digest order survives intact. @Test fun tsCarryForwardKeepsDigestOrder() { val digest = turns( """{"turns":[ {"input":"a","input_seq":1,"entries":[ {"Agent":{"text":"one","seq":2,"ts":"1970-01-01T00:00:04Z"}}, {"Boundary":{"kind":"clear"}} ]}, {"input":"b","input_seq":3,"entries":[ {"Agent":{"text":"two","seq":4,"ts":"1970-01-01T00:00:09Z"}} ],"partial":true} ]}""" ) val history = listOf(row("m1", 6_000, dir = "in", body = "mid")) val rows = Timeline.interlace(history, emptyList(), digest, emptySet()) val texts = rows.map { when (it) { is TimelineRow.Message -> it.body is TimelineRow.DigestEntry -> it.text is TimelineRow.DigestInput -> it.text } } // Boundary carries 4s forward; input "b" also rides 4s; history@6s // splits before "two"@9s. assertEquals(listOf("a", "one", "clear", "b", "mid", "two"), texts) } // [unit->REQ-ENDPOINT-VIEW-INTERLACE] // A digest with no ts anywhere is the live now-window: it sorts after all // history rather than interleaving on fuzzy guesses. @Test fun tslessDigestLandsAfterHistory() { val digest = turns( """{"turns":[{"input":"q","input_seq":1,"entries":[ {"Agent":{"text":"hi","seq":2}} ]}]}""" ) val history = listOf(row("m1", 99_000, dir = "in", body = "old")) val rows = Timeline.interlace(history, emptyList(), digest, emptySet()) assertTrue(rows[0] is TimelineRow.Message) assertEquals("q", (rows[1] as TimelineRow.DigestInput).text) assertEquals("hi", (rows[2] as TimelineRow.DigestEntry).text) } // [unit->REQ-HAZARD-DUP-ROWS] // The digest echo of the phone's own send collapses by EXACT msg-id — // whether the send is a session-local pending one (ownMsgIds) or already // in the fetched history as an outbound row. @Test fun ownSendEchoCollapses() { val echoBody = "hello" val digest = turns( """{"turns":[{"input":null,"input_seq":1,"entries":[ {"Context":{"kind":"owl_message","body":"$echoBody","ts":"1970-01-01T00:00:05Z"}}, {"Agent":{"text":"reply","seq":2,"ts":"1970-01-01T00:00:06Z"}} ]}]}""" ) // Case 1: send known only session-locally. var rows = Timeline.interlace( emptyList(), listOf(row("u-1", 5_000, dir = "out", body = "hello")), digest, setOf("u-1"), ) assertEquals( "echo dropped, message + reply survive", listOf("hello", "reply"), rows.mapNotNull { when (it) { is TimelineRow.Message -> it.body is TimelineRow.DigestEntry -> it.text else -> null } }, ) // Case 2: same send arrived via history fetch; no session set needed. rows = Timeline.interlace( listOf(row("u-1", 5_000, dir = "out", body = "hello")), emptyList(), digest, emptySet(), ) assertEquals(2, rows.size) // A DIFFERENT msg-id must not collapse (exact axis, never fuzzy). rows = Timeline.interlace( listOf(row("u-9", 5_000, dir = "out", body = "hello")), emptyList(), digest, emptySet(), ) assertEquals(3, rows.size) } // [unit->REQ-HAZARD-DUP-ROWS] // A locally-appended own send collapses with the same row arriving off a // later history fetch — one row per msg-id, exact key only; rows WITHOUT // a msg-id are never fuzzy-matched away. @Test fun historyMsgIdDedupIsExact() { val fetched = listOf( row("u-1", 5_000, dir = "out", body = "hello"), row(null, 6_000, dir = "in", body = "anon-a"), row(null, 6_000, dir = "in", body = "anon-a"), ) val local = listOf(row("u-1", 5_100, dir = "out", body = "hello")) val rows = Timeline.interlace(fetched, local, emptyList(), emptySet()) // u-1 once (fetched wins), both anonymous rows kept. assertEquals(3, rows.size) assertEquals(5_000L, (rows[0] as TimelineRow.Message).tsMs) } // [unit->REQ-MULTI-HOST-PAIRING] // The history axis is the UNION across paired hosts (ruling 8): rows // fetched from two hosts concatenate in dial order, the same send // reported by both collapses by msg-id (first/highest-priority wins), // and each host's unique rows interleave by timestamp. @Test fun crossHostHistoryUnionCollapsesByMsgId() { val fromHostA = listOf( row("m-1", 1_000, dir = "out", body = "sent via a"), row(null, 4_000, dir = "in", body = "only a saw this"), ) val fromHostB = listOf( row("m-1", 1_000, dir = "out", body = "sent via a"), row("m-2", 2_000, dir = "out", body = "sent via b"), ) val rows = Timeline.interlace(fromHostA + fromHostB, emptyList(), emptyList(), emptySet()) val bodies = rows.map { (it as TimelineRow.Message).body } assertEquals(listOf("sent via a", "sent via b", "only a saw this"), bodies) } // [unit->REQ-HAZARD-DIGEST-CARD-COLLAPSE] // The collapse-fix invariant: an idle-tick republish of UNCHANGED content // yields byte-identical rows AND identical stableKeys — so StateFlow // conflates the re-emit (no flicker) and LazyColumn keeps every item's // hoisted state (no collapsed card). Keys are also unique across the view. @Test fun stableKeysAreStableAndUniqueAcrossRepublish() { val digest = turns( """{"turns":[ {"input":"a","input_seq":1,"entries":[ {"Agent":{"text":"one","seq":2,"ts":"1970-01-01T00:00:04Z"}}, {"Boundary":{"kind":"clear"}} ]}, {"input":"b","entries":[ {"Agent":{"text":"two","seq":5,"ts":"1970-01-01T00:00:09Z"}} ],"partial":true} ]}""" ) val history = listOf(row("m1", 6_000, dir = "in", body = "mid")) val first = Timeline.interlace(history, emptyList(), digest, emptySet()) val second = Timeline.interlace(history, emptyList(), digest, emptySet()) // Value-equal (StateFlow conflates → no re-emit, no flicker)… assertEquals(first, second) // …and key-stable row-for-row (LazyColumn preserves item state). assertEquals(first.map { it.stableKey }, second.map { it.stableKey }) // Keys are unique — a collision would key two rows to one slot. val keys = first.map { it.stableKey } assertEquals(keys.size, keys.toSet().size) // Rows anchor on (turn, ordinal) — NEVER a bare seq, which is absent on // a live entry and appears later (that flip is what churned the anchor). assertTrue(keys.any { it == "din:1" }) // input "a" → input_seq 1 assertTrue(keys.any { it == "den:1/0" }) // agent "one" → turnKey/ordinal assertTrue(keys.any { it == "den:1/1" }) // boundary → turnKey/ordinal assertTrue(keys.any { it == "din:open" }) // the live (partial) turn } // [unit->REQ-HAZARD-DIGEST-CARD-COLLAPSE] // The heartbeat-jump bug: the LIVE turn's entries are seqless, then get seqs // assigned as they finalize. Keys MUST NOT flip when that happens, or the // list loses its scroll anchor every heartbeat. Same live entry (same // ordinal), seqless then seq'd → identical stableKey. @Test fun liveEntryKeepsItsKeyWhenItGainsASeq() { fun snapshot(withSeq: Boolean) = turns( """{"turns":[{"input":"go","entries":[ {"Agent":{"text":"streaming…"${if (withSeq) ""","seq":42""" else ""}}} ],"partial":true}]}""" ) val before = Timeline.interlace(emptyList(), emptyList(), snapshot(false), emptySet()) val after = Timeline.interlace(emptyList(), emptyList(), snapshot(true), emptySet()) assertEquals(before.map { it.stableKey }, after.map { it.stableKey }) assertTrue(after.any { it.stableKey == "den:open/0" }) } @Test fun parseHistoryReadsWireShape() { val rows = Timeline.parseHistory( """[{"msg_id":"m","ts_ms":12,"dir":"in","from":"a","body":"x","json":"{}"}, {"ts_ms":13,"dir":"out","from":"b","body":"y"}]""" ) assertEquals(2, rows.size) assertEquals("m", rows[0].msg_id) assertEquals(null, rows[1].msg_id) } }