package org.terst.doot.widget import org.junit.Assert.* import org.junit.Test import org.terst.doot.widget.data.PENDING_REMOVAL_TTL_MS import org.terst.doot.widget.data.WidgetItem import org.terst.doot.widget.data.decodePendingRemovals import org.terst.doot.widget.data.encodePendingRemovals import org.terst.doot.widget.data.excludingUnexpiredPendingRemovals import org.terst.doot.widget.data.pendingKey import org.terst.doot.widget.data.pruneExpired class PendingRemovalsTest { private fun taskItem(id: String, source: String = "doot") = WidgetItem(id = id, title = "Task $id", source = source, type = "task") @Test fun `excludingUnexpiredPendingRemovals filters out a pending item`() { val items = listOf(taskItem("a"), taskItem("b")) val now = 1_000_000L val pending = mapOf(pendingKey("b", "doot") to now) val result = items.excludingUnexpiredPendingRemovals(pending, now) assertEquals(listOf("a"), result.map { it.id }) } @Test fun `excludingUnexpiredPendingRemovals is a no-op with an empty pending map`() { val items = listOf(taskItem("a"), taskItem("b")) assertEquals(items, items.excludingUnexpiredPendingRemovals(emptyMap(), 1_000_000L)) } // This is the actual regression: a slower, earlier-started fetch's response still // includes an item that a faster, later-started completion has since removed. Without // pending-removal protection, persisting that stale snapshot resurrects the item -- // this is what made "check to complete" look like it stopped working after the first // tap (2026-08-05), even though every completion request was independently succeeding // server-side. @Test fun `a stale fetch snapshot captured before a completion cannot resurrect it`() { val staleSnapshotStillContainingB = listOf(taskItem("a"), taskItem("b")) val completedAt = 1_000_000L val pendingAfterCompletingB = mapOf(pendingKey("b", "doot") to completedAt) // The stale fetch's write happens *after* b was completed, wall-clock, even though // its data was captured before -- exactly the race: slower fetch, later write. val persisted = staleSnapshotStillContainingB .excludingUnexpiredPendingRemovals(pendingAfterCompletingB, completedAt + 500) assertEquals(listOf("a"), persisted.map { it.id }) } @Test fun `a pending removal expires and stops protecting after the TTL`() { val items = listOf(taskItem("a"), taskItem("b")) val removedAt = 1_000_000L val pending = mapOf(pendingKey("b", "doot") to removedAt) // Just before expiry: still protected. val stillProtected = items.excludingUnexpiredPendingRemovals(pending, removedAt + PENDING_REMOVAL_TTL_MS - 1) assertEquals(listOf("a"), stillProtected.map { it.id }) // At/after expiry: self-healing kicks in -- a permanently-failed completion must // not hide its task forever, matching the existing periodic-refresh self-healing // property elsewhere in this codebase. val expired = items.excludingUnexpiredPendingRemovals(pending, removedAt + PENDING_REMOVAL_TTL_MS) assertEquals(listOf("a", "b"), expired.map { it.id }) } @Test fun `different source with the same id is not confused for the pending item`() { val items = listOf(taskItem("shared-id", source = "trello")) val pending = mapOf(pendingKey("shared-id", "doot") to 1_000_000L) val result = items.excludingUnexpiredPendingRemovals(pending, 1_000_000L) assertEquals(listOf("shared-id"), result.map { it.id }) } @Test fun `pruneExpired drops old entries and keeps fresh ones`() { val now = 1_000_000L val pending = mapOf( "fresh" to now, "stale" to now - PENDING_REMOVAL_TTL_MS ) val pruned = pending.pruneExpired(now) assertEquals(setOf("fresh"), pruned.keys) } @Test fun `encode and decode round-trip`() { val pending = mapOf(pendingKey("a", "doot") to 42L) val decoded = decodePendingRemovals(encodePendingRemovals(pending)) assertEquals(pending, decoded) } @Test fun `decodePendingRemovals treats null and malformed input as empty`() { assertEquals(emptyMap(), decodePendingRemovals(null)) assertEquals(emptyMap(), decodePendingRemovals("not json")) } }