Merge branch 'mantra' into claude/torch-intro-boot-hang-76b0a8
This commit is contained in:
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user