package com.example.aiapp import java.time.ZoneId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue /** * The model's working as its own row, and the line under a finished reply. * * Both have the same shape of hazard: a state nothing measured must not come out looking like one * that was. A block interrupted mid-thought has no duration, and a provider that reports no * generation speed has no figure -- neither may borrow one. */ class ThinkingTest { private val utc = ZoneId.of("UTC") private var seq = 0L private fun fold(items: List, event: SessionEvent, ts: Double = 1.0) = foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event)) private fun fold(vararg events: SessionEvent) = events.fold(emptyList()) { items, event -> fold(items, event) } private fun thinking(items: List) = items.filterIsInstance() @Test fun `deltas accumulate into one block that ends with its duration`() { val items = fold( SessionEvent.Thinking("the user "), SessionEvent.Thinking("wants a card"), SessionEvent.ThinkingDone(12_400), SessionEvent.AssistantText("Here it is."), ) val block = thinking(items).single() assertEquals("the user wants a card", block.text) assertEquals(12_400, block.ms) assertEquals("Thought for 12.4s", thinkingHeadline(block)) // Its own row, above the reply rather than inside it. assertEquals(1, items.filterIsInstance().size) } @Test fun `a block the turn ended in the middle of stops without naming a span`() { val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle")) val block = thinking(items).single() assertNull(block.ms) assertTrue(!block.open) assertEquals("Thought", thinkingHeadline(block)) } @Test fun `a block still being thought says so`() { val block = thinking(fold(SessionEvent.Thinking("hmm"))).single() assertTrue(block.open) assertEquals("Thinking", thinkingHeadline(block)) } @Test fun `thinking between two replies is two replies and two blocks`() { val items = fold( SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000), SessionEvent.AssistantText("One."), SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000), SessionEvent.AssistantText("Two."), ) assertEquals(listOf("first", "second"), thinking(items).map { it.text }) assertEquals( listOf("One.", "Two."), items.filterIsInstance().map { it.text }, ) } @Test fun `a reply carries when it was sent and what it cost to produce`() { val items = fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let { fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489)) } val reply = items.filterIsInstance().single() assertEquals(1_788_609_600.0, reply.ts) assertEquals(18.37, reply.tokensPerSecond) assertEquals(9_489, reply.prefillMs) val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc) // The clock reading rather than the whole string: the platform's own short-time format // differs by JDK and locale, which is the point of asking it for one. assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer) assertTrue(footer.contains("12:00"), footer) } @Test fun `the clock stays at the end however much the provider measured`() { // What a provider that measures nothing leaves: the time, and nothing in front of it. val bare = replyFooterText(1_788_609_600.0, null, null, utc) assertTrue(bare!!.contains("12:00"), bare) assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare) // Every shape ends with the same thing, which is the whole point of the order: the clock // does not move because the session is on a provider that measures more or less. val shapes = listOf( bare, replyFooterText(1_788_609_600.0, 18.37, null, utc)!!, replyFooterText(1_788_609_600.0, null, 9_489, utc)!!, replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!, ) assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes") // A reply with nothing to say has no line at all rather than an empty one. assertNull(replyFooterText(0.0, null, null, utc)) } @Test fun `a block cut by a page boundary is one block, and it is not still going`() { // Each page folded on its own, as the app does: the older one holds the fragments before // the cut and no ending, the newer one the rest and the ending. val older = fold(SessionEvent.Thinking("half a ")) val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000)) val joined = joinPages(older, newer) val block = thinking(joined).single() assertEquals("half a thought", block.text) assertEquals(2_000, block.ms) assertTrue(!block.open) } @Test fun `two blocks meeting at a page boundary stay two`() { val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000)) val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000)) assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text }) } @Test fun `usage that lands after a tool call is not folded onto an older reply`() { val items = fold( SessionEvent.AssistantText("Reading it."), SessionEvent.ToolStart("t1", "Read", "{}"), SessionEvent.ToolEnd("t1", "done"), SessionEvent.UsageDelta(42, 100, 18.0, 500), ) assertNull(items.filterIsInstance().single().tokensPerSecond) } }