Merge branch 'mantra' into claude/torch-intro-boot-hang-76b0a8

This commit is contained in:
Kgothatso Ngako
2026-09-06 20:44:42 +02:00
36 changed files with 13844 additions and 139 deletions

View File

@@ -0,0 +1,103 @@
package press.mantra.compose.database.model
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* Which of a group's kind:445 events are this device's to read at all.
*
* MLS hands a joiner the key schedule from their own epoch forward and nothing
* before it, but the relay hands them the whole room. Everything the group
* published earlier still syncs down and is still permanently unreadable, so a
* member invited into a busy room opened it on a run of "Undecryptable Message"
* above the conversation they were invited to.
*
* The epoch an event was encrypted under is inside the layer that will not
* decrypt, so the only thing left to ask is when it was published. That makes
* the boundary a judgement call rather than a fact, and the direction it errs in
* is what these pin down: an unreadable event kept costs one refused decrypt, a
* readable event discarded is a message the member never sees.
*/
class ChatRoomMembershipWindowTest {
private val roomId = "a".repeat(64)
private val user = "b".repeat(64)
private fun room(
joinedGroupAt: Instant?,
createdAt: Instant = Instant.fromEpochSeconds(5_000),
) = ChatRoom(
id = roomId,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
joinedGroupAt = joinedGroupAt,
createdAt = createdAt,
)
@Test
fun `a message from before the welcome is not this devices to read`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(1_999)))
}
@Test
fun `a message from after the welcome is`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_001)))
}
/**
* The boundary, and the reason it is drawn strictly.
*
* Nostr stamps `created_at` in whole seconds, so the second the Welcome was
* minted holds both the commit that added us -- the last act of the epoch
* before ours, unreadable by construction -- and any message another member
* sent the instant they applied it. Only one of those two can be had, and a
* message is worth more than a spared decrypt.
*/
@Test
fun `a message from the very second of the welcome is kept`() {
val chatRoom = room(joinedGroupAt = Instant.fromEpochSeconds(2_000))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(2_000)))
}
/**
* A room joined before the column existed. `createdAt` is when this device
* wrote the row down, which for a joiner is the Welcome it wrote it from --
* the same answer every path that sets `joinedGroupAt` would have written.
*/
@Test
fun `a room with no recorded join falls back to when it was written down`() {
val chatRoom = room(joinedGroupAt = null, createdAt = Instant.fromEpochSeconds(5_000))
assertEquals(Instant.fromEpochSeconds(5_000), chatRoom.memberSince)
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(4_999)))
assertFalse(chatRoom.predatesMembership(Instant.fromEpochSeconds(5_000)))
}
/**
* A device that joined a room it had already heard of -- its own invite
* gift wrap arrived out of order, say, and the row was written before the
* Welcome was processed. The recorded join is the group's own account of
* when our epoch began and beats this device's account of when it started
* keeping notes.
*/
@Test
fun `a recorded join wins over when the row was written`() {
val chatRoom = room(
joinedGroupAt = Instant.fromEpochSeconds(9_000),
createdAt = Instant.fromEpochSeconds(5_000),
)
assertEquals(Instant.fromEpochSeconds(9_000), chatRoom.memberSince)
assertTrue(chatRoom.predatesMembership(Instant.fromEpochSeconds(6_000)))
}
}

View File

@@ -0,0 +1,225 @@
package press.mantra.compose.database.model.intermdiate
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Profile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.time.Instant
/**
* The one line under a room's name in the chat list.
*
* It is a summary of somebody's message shown next to their name, which is the shape
* every way of getting this wrong takes: attributing a ceremony milestone to the member
* who happened to trigger it, showing an empty row where a private message this device
* cannot open should say so, or putting a name in front of a line in a two-person room
* where the name is already the title.
*/
class ChatRoomLastMessagePreviewTest {
private val user = "a".repeat(64)
private val alice = "b".repeat(64)
private val bob = "c".repeat(64)
private fun room(
participants: List<String>,
lastChatMessage: ChatRoomLastMessage?,
) = LocalChatRoom(
chatRoom = ChatRoom(
id = "11".repeat(32),
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
),
localParticipants = (participants + user).map { publicKey ->
LocalParticipant(
participant = Participant(
participantPublicKey = publicKey,
chatRoomId = "11".repeat(32),
relayHint = null,
),
profile = Profile(
publicKey = publicKey,
userName = when (publicKey) {
alice -> "Alice"
bob -> "Bob"
else -> "Me"
},
nostrEventId = "d".repeat(64),
),
)
},
lastChatMessage = lastChatMessage,
)
private fun message(
content: String = "hello",
sender: String = alice,
messageType: String = "message",
directMessageRecipientPublicKey: String? = null,
) = ChatRoomLastMessage(
senderPublicKey = sender,
isUserMessage = sender == user,
content = content,
messageType = messageType,
directMessageRecipientPublicKey = directMessageRecipientPublicKey,
createdAt = Instant.fromEpochSeconds(1_000),
)
@Test
fun `a room with nothing said in it has no preview`() {
assertNull(room(listOf(alice), lastChatMessage = null).lastChatMessagePreviewText())
}
/** The other party's name is the row's title already; repeating it says nothing. */
@Test
fun `a two-person room shows the words alone`() {
assertEquals(
"hello",
room(listOf(alice), message(content = "hello")).lastChatMessagePreviewText()
)
}
@Test
fun `a group names who spoke`() {
assertEquals(
"Alice: hello",
room(listOf(alice, bob), message(content = "hello")).lastChatMessagePreviewText()
)
}
/** True in both room shapes: the title never names the reader. */
@Test
fun `the reader's own message is prefixed either way`() {
assertEquals(
"You: hello",
room(listOf(alice), message(content = "hello", sender = user)).lastChatMessagePreviewText()
)
assertEquals(
"You: hello",
room(listOf(alice, bob), message(content = "hello", sender = user)).lastChatMessagePreviewText()
)
}
/**
* Ritual content is written as a predicate for the actor's name, so an authored line
* needs it and reads as nonsense without it.
*/
@Test
fun `an authored ritual line reads as its actor doing it`() {
assertEquals(
"Alice published their share",
room(
listOf(alice, bob),
message(content = "published their share", messageType = ChatMessage.TYPE_DKG_ROUND_1)
).lastChatMessagePreviewText()
)
}
/**
* The group ends up with a key; nobody hands it to them. A name in front of this
* would read as the member having announced it.
*/
@Test
fun `a ritual line nobody authored stands alone`() {
assertEquals(
"The group now has a shared key",
room(
listOf(alice, bob),
message(
content = "The group now has a shared key",
messageType = ChatMessage.TYPE_DKG_COMPLETE
)
).lastChatMessagePreviewText()
)
}
/** A chronicle is not anybody's words either, whichever end of it this device was on. */
@Test
fun `a chronicle line stands alone`() {
assertEquals(
"Caught up on 12 items",
room(
listOf(alice, bob),
message(
content = "Caught up on 12 items",
messageType = ChatMessage.TYPE_CHRONICLE_RECEIVED
)
).lastChatMessagePreviewText()
)
}
/**
* An invite is nobody's words either, and its content is a whole sentence with the
* invitee's name already in it. A prefix here reads as the inviter having said
* "Invited Bob to the group" out loud.
*/
@Test
fun `a membership line stands alone`() {
ChatMessage.MEMBERSHIP_TYPES.forEach { messageType ->
assertEquals(
"Invited Bob to the group",
room(
listOf(alice, bob),
message(content = "Invited Bob to the group", messageType = messageType)
).lastChatMessagePreviewText(),
"$messageType should not be attributed to its sender"
)
}
}
/**
* The bystanders' copy of a private message has no content. An empty preview would
* read as the sender having said nothing, so the line says what the group can in
* fact see: that a private message was sent, and to whom.
*/
@Test
fun `a private message this device cannot open says so`() {
assertEquals(
"Alice sent a private message to Bob",
room(
listOf(alice, bob),
message(
content = "",
messageType = ChatMessage.TYPE_DIRECT_MESSAGE,
directMessageRecipientPublicKey = bob,
)
).lastChatMessagePreviewText()
)
}
/** A private message this device can read is a message like any other. */
@Test
fun `a readable private message shows its words`() {
assertEquals(
"Alice: just between us",
room(
listOf(alice, bob),
message(
content = "just between us",
messageType = ChatMessage.TYPE_DIRECT_MESSAGE,
directMessageRecipientPublicKey = user,
)
).lastChatMessagePreviewText()
)
}
/**
* A sender the room has no participant row for -- a member who has since left, or one
* whose profile has not arrived. Falls back to a shortened key rather than dropping
* the attribution, which would silently reassign the words to nobody.
*/
@Test
fun `an unknown sender is named by their key`() {
val stranger = "e".repeat(64)
assertEquals(
"${stranger.take(8)}: hello",
room(listOf(alice, bob), message(sender = stranger)).lastChatMessagePreviewText()
)
}
}

View File

@@ -0,0 +1,97 @@
package press.mantra.compose.extensions
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
/**
* The clock on a chat list row. It has one job -- placing a message relative to now --
* and each rung is only unambiguous over a bounded span: a bare time means nothing on
* a message from last week, a weekday means nothing past six days, and a day and month
* mean nothing past a year. Reading a rung too far is the failure mode, and it is silent.
*
* Every case is anchored to the local day rather than to a fixed instant, because the
* boundaries are local midnights and the test would otherwise pass or fail on the
* machine's zone.
*/
class ChatListTimestampTest {
private val timeZone = TimeZone.currentSystemDefault()
/** Midday today, local, so no case lands on a boundary by accident. */
private val now = Instant.parse("2026-09-06T00:00:00Z")
.toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone) + 12.hours
private fun daysBefore(days: Int) = now - days.days
@Test
fun `today shows the time of day`() {
val formatted = daysBefore(0).toChatListTimestampString(now)
assertEquals(
now.toLocalDateTime(timeZone).let {
"${it.hour.toString().padStart(2, '0')}:${it.minute.toString().padStart(2, '0')}"
},
formatted
)
}
@Test
fun `yesterday is named rather than dated`() {
assertEquals("Yesterday", daysBefore(1).toChatListTimestampString(now))
}
/**
* Two through six days. A seventh would be this weekday again, which reads as today.
*/
@Test
fun `the rest of the last week shows a weekday`() {
(2..6).forEach { days ->
val formatted = daysBefore(days).toChatListTimestampString(now)
assertEquals(3, formatted.length, "$days days ago should be an abbreviated weekday")
assertTrue(
formatted.none { it.isDigit() },
"$days days ago should be a weekday, not a date: $formatted"
)
}
}
/** A week out the weekday has stopped meaning anything, so the date takes over. */
@Test
fun `beyond a week shows a day and month`() {
val formatted = daysBefore(7).toChatListTimestampString(now)
val date = daysBefore(7).toLocalDateTime(timeZone).date
assertEquals("${date.day} ${date.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)}", formatted)
}
/** Past a year the day and month repeat, so the year has to be said. */
@Test
fun `a message from another year carries its year`() {
val formatted = daysBefore(400).toChatListTimestampString(now)
val date = daysBefore(400).toLocalDateTime(timeZone).date
assertTrue(
formatted.endsWith(" ${date.year}"),
"a message from ${date.year} should say so: $formatted"
)
}
/**
* Relay clocks disagree and an event can arrive stamped ahead of this device. Showing
* it as a bare time would file it under a today it does not belong to.
*/
@Test
fun `a timestamp from the future gets a date rather than a time`() {
val formatted = (now + 2.days).toChatListTimestampString(now)
assertTrue(':' !in formatted, "a future timestamp should not read as a time today: $formatted")
}
}