Polls where nobody can tell who voted for what
The secure-aggregation core has been in the engine since it was written and nothing used it: there was no way to ask a poll, vote in one, or see a result. This is the rest. `PollBook` is the sequencing, deliberately the same shape as the iOS one. A poll crosses between the two engines, so a rule enforced on one side and not the other is a poll that tallies differently depending on who is looking at it. No sum is published until every member has voted: a member who has not voted has handed nobody a piece, so nobody holds a complete set, and a sum published early is noise rather than a partial answer. Asking a poll sends the same envelope to the group. Voting does not: each piece is addressed to the member it belongs to and sent to them alone. That is the one group envelope in the app which is not the same bytes to everybody, and a test pins it, because sending one share to the group would let any member subtract it from the tally and read the vote, with the counts still coming out right and nothing at runtime to notice. Kept in the vault, or a poll could never finish: it waits for everybody, and the pieces gathered would be gone the first time the app closed. The card says what the maths actually gives. Everyone else would have to work together to know how one person voted, never that nobody can know. A poll of two is not anonymous at all, because the other person holds a piece of the vote and sees the tally, so the word is not offered for a pair and the card says why. 22 new tests. 647 in the engine and 430 in the client, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
13f55076b9
commit
315e6d328f
@@ -1592,6 +1592,9 @@ fun ColumnScope.ChatScreen(
|
||||
// Recorded by ID at the moment of picking, never re-derived from the text
|
||||
// when sending.
|
||||
var pendingMentions by remember { mutableStateOf(emptySet<Long>()) }
|
||||
|
||||
/** Whether the new-poll dialog is up (§6.29). */
|
||||
var askingPoll by remember(chat.threadId) { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Open AT the newest message, a jump, not a tour of the history.
|
||||
@@ -1654,6 +1657,12 @@ fun ColumnScope.ChatScreen(
|
||||
}
|
||||
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
|
||||
chat.group?.let { group ->
|
||||
IconAction(
|
||||
icon = KitIcon.Poll,
|
||||
label = "New poll",
|
||||
tag = "new-poll",
|
||||
onClick = { askingPoll = true },
|
||||
)
|
||||
IconAction(
|
||||
icon = KitIcon.LeaveGroup,
|
||||
label = "Leave group",
|
||||
@@ -1691,6 +1700,21 @@ fun ColumnScope.ChatScreen(
|
||||
/** The message the next send answers (android#16). */
|
||||
var replying by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
|
||||
|
||||
// §6.29. Above the messages rather than in them: a poll is not a bubble,
|
||||
// it changes as pieces arrive, and it has to stay reachable while people
|
||||
// are still voting.
|
||||
chat.polls.forEach { poll ->
|
||||
PollCard(model, poll) { id ->
|
||||
buddies.firstOrNull { it.userId == id }?.displayName ?: "user $id"
|
||||
}
|
||||
}
|
||||
|
||||
if (askingPoll) {
|
||||
chat.group?.let { group ->
|
||||
NewPollDialog(model, group, myUserId) { askingPoll = false }
|
||||
}
|
||||
}
|
||||
|
||||
// The pinned banner, above the messages: a pin nobody can see is a pin
|
||||
// nobody uses.
|
||||
chat.pinnedMessageId?.let { pinnedId ->
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.outlined.Logout
|
||||
import androidx.compose.material.icons.automirrored.outlined.Send
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.outlined.BarChart
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.AddCircleOutline
|
||||
@@ -99,6 +100,9 @@ object KitIcon {
|
||||
val Verified: ImageVector = Icons.Outlined.Verified
|
||||
val LeaveGroup: ImageVector = Icons.AutoMirrored.Outlined.Logout
|
||||
|
||||
/** Asking a group something (§6.29). */
|
||||
val Poll: ImageVector = Icons.Outlined.BarChart
|
||||
|
||||
/** Presence and unread dots, where a filled circle IS the icon. */
|
||||
val Dot: ImageVector = Icons.Filled.Circle
|
||||
|
||||
|
||||
@@ -201,6 +201,12 @@ class MessengerViewModel(
|
||||
* The message pinned in this conversation (§6.19), or null.
|
||||
*/
|
||||
val pinnedMessageId: String? = null,
|
||||
/**
|
||||
* Polls asked in this group (§6.29). Carried on the chat because a
|
||||
* poll is not a message: it changes as pieces and sums arrive,
|
||||
* which touches no thread at all.
|
||||
*/
|
||||
val polls: List<io.flamenet.e2e.PollBook.Entry> = emptyList(),
|
||||
/**
|
||||
* What we believe about the other side's identity keys (§19).
|
||||
*/
|
||||
@@ -1147,6 +1153,53 @@ class MessengerViewModel(
|
||||
/**
|
||||
* Leaving also clears the local thread.
|
||||
*/
|
||||
// ---- Polls (§6.29) ----------------------------------------------------.
|
||||
|
||||
/** Polls asked in this group, for the cards above the messages. */
|
||||
fun pollsIn(groupId: String): List<io.flamenet.e2e.PollBook.Entry> =
|
||||
session?.conversations?.polls?.pollsIn(groupId).orEmpty().sortedBy { it.id }
|
||||
|
||||
/** Ask a poll of a group. Nothing is voted here. */
|
||||
fun askPoll(
|
||||
group: io.flamenet.client.RelayClient.Group,
|
||||
question: String,
|
||||
options: List<String>,
|
||||
) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { current.conversations.askPoll(group, current.userId, question, options) }
|
||||
}
|
||||
refreshPolls()
|
||||
}
|
||||
|
||||
/**
|
||||
* Vote. Each piece goes to the member it belongs to and to nobody else:
|
||||
* the whole point is that no individual vote is ever sent anywhere.
|
||||
*/
|
||||
fun votePoll(
|
||||
pollId: String,
|
||||
choice: Int,
|
||||
) = viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { current.conversations.vote(pollId, current.userId, choice) }
|
||||
}
|
||||
refreshPolls()
|
||||
}
|
||||
|
||||
private fun refreshPolls() {
|
||||
val current = session ?: return
|
||||
update { state ->
|
||||
val gid = state.openChat?.group?.id ?: return@update state
|
||||
state.copy(
|
||||
openChat =
|
||||
state.openChat.copy(
|
||||
polls = current.conversations.polls.pollsIn(gid).sortedBy { it.id },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun leaveGroup(groupId: String) =
|
||||
viewModelScope.launch {
|
||||
val current = session ?: return@launch
|
||||
@@ -3764,10 +3817,27 @@ class MessengerViewModel(
|
||||
state.copy(
|
||||
openChat =
|
||||
open?.let {
|
||||
if (it.threadId in threads) {
|
||||
it.copy(lines = current.history(it.threadId))
|
||||
val withLines =
|
||||
if (it.threadId in threads) {
|
||||
it.copy(lines = current.history(it.threadId))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
// Poll traffic is control traffic:
|
||||
// it touches no thread, so it never
|
||||
// appears in `threads` and a card
|
||||
// waiting on somebody else's vote
|
||||
// would never have moved.
|
||||
val gid = withLines.group?.id
|
||||
if (gid == null) {
|
||||
withLines
|
||||
} else {
|
||||
it
|
||||
withLines.copy(
|
||||
polls =
|
||||
current.conversations.polls
|
||||
.pollsIn(gid)
|
||||
.sortedBy { p -> p.id },
|
||||
)
|
||||
}
|
||||
},
|
||||
// A thread that is already open is being
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Poll
|
||||
import io.flamenet.e2e.PollBook
|
||||
|
||||
/**
|
||||
* A poll in a group, and what it is waiting for (§6.29).
|
||||
*
|
||||
* The screen carries two claims the maths actually supports and no more: that
|
||||
* nobody is sent an individual vote, and that everybody other than the voter
|
||||
* would have to work together to recover one. It never says "nobody can know",
|
||||
* and it never shows a count before every member has published, because a
|
||||
* missing sum leaves noise in the total rather than a smaller number.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.PollCard(
|
||||
model: MessengerViewModel,
|
||||
poll: PollBook.Entry,
|
||||
name: (Long) -> String,
|
||||
) {
|
||||
val standing = poll.standing
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(Palette.Accent.copy(alpha = 0.08f))
|
||||
.padding(14.dp)
|
||||
.testTag("poll-${poll.id}"),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
// A poll of two is not anonymous: the other member holds a piece of
|
||||
// your vote and sees the tally, and subtracting gives it, with nobody
|
||||
// to collude with. The word is not offered for a pair.
|
||||
Text(
|
||||
if (poll.members.size > 2) "Anonymous poll" else "Poll",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Text(poll.question, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
|
||||
when (standing) {
|
||||
is PollBook.Standing.Counted -> Results(poll, standing.counts)
|
||||
else -> {
|
||||
if (poll.myChoice == null) {
|
||||
poll.options.forEachIndexed { index, option ->
|
||||
Text(
|
||||
option,
|
||||
fontSize = 15.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SecondaryText.copy(alpha = 0.12f))
|
||||
.clickable { model.votePoll(poll.id, index) }
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp)
|
||||
.testTag("poll-option-$index"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"You chose ${poll.options[poll.myChoice ?: 0]}.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
waitingLine(poll, standing, name),
|
||||
fontSize = 13.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
}
|
||||
|
||||
private fun waitingLine(
|
||||
poll: PollBook.Entry,
|
||||
standing: PollBook.Standing,
|
||||
name: (Long) -> String,
|
||||
): String =
|
||||
when (standing) {
|
||||
is PollBook.Standing.WaitingForVotes ->
|
||||
if (poll.myChoice == null) {
|
||||
"Nobody sees a count until everyone has voted."
|
||||
} else {
|
||||
"Waiting for ${list(standing.from, name)} to vote."
|
||||
}
|
||||
|
||||
is PollBook.Standing.WaitingForSums ->
|
||||
"Everyone voted. Counting, waiting for ${list(standing.from, name)}."
|
||||
|
||||
is PollBook.Standing.Counted -> ""
|
||||
}
|
||||
|
||||
private fun list(
|
||||
ids: List<Long>,
|
||||
name: (Long) -> String,
|
||||
): String {
|
||||
val names = ids.map(name)
|
||||
return when (names.size) {
|
||||
0 -> "nobody"
|
||||
1 -> names[0]
|
||||
2 -> "${names[0]} and ${names[1]}"
|
||||
else -> names.dropLast(1).joinToString(", ") + " and " + names.last()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.Results(
|
||||
poll: PollBook.Entry,
|
||||
counts: IntArray,
|
||||
) {
|
||||
val total = maxOf(counts.sum(), 1)
|
||||
poll.options.forEachIndexed { index, option ->
|
||||
val count = counts.getOrElse(index) { 0 }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(option, fontSize = 14.sp, modifier = Modifier.weight(1f))
|
||||
Text("$count", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SecondaryText.copy(alpha = 0.15f)),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(maxOf(count, 0).toFloat() / total.toFloat())
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.Accent),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(Space.xs))
|
||||
}
|
||||
// The guarantee, stated as it actually is.
|
||||
Text(
|
||||
if (poll.members.size > 2) {
|
||||
"Nobody was sent anybody's vote. Everyone else here would have to " +
|
||||
"work together to know how you voted."
|
||||
} else {
|
||||
"Nobody was sent your vote, but in a poll of two the other person " +
|
||||
"can work it out from the result."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
/** Asking one. */
|
||||
@Composable
|
||||
fun NewPollDialog(
|
||||
model: MessengerViewModel,
|
||||
group: io.flamenet.client.RelayClient.Group,
|
||||
myUserId: Long,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var question by remember { mutableStateOf("") }
|
||||
var options by remember { mutableStateOf(listOf("", "")) }
|
||||
val members = (group.memberIds + myUserId).distinct().size
|
||||
val filled = options.map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val ready = question.isNotBlank() && filled.size >= 2 && members >= 2
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New poll") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||
OutlinedTextField(
|
||||
value = question,
|
||||
onValueChange = { question = it },
|
||||
label = { Text("Ask something") },
|
||||
singleLine = false,
|
||||
modifier = Modifier.fillMaxWidth().testTag("poll-question"),
|
||||
)
|
||||
options.forEachIndexed { i, value ->
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { text ->
|
||||
options = options.toMutableList().also { it[i] = text }
|
||||
},
|
||||
label = { Text("Option ${i + 1}") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("poll-new-option-$i"),
|
||||
)
|
||||
}
|
||||
if (options.size < Poll.MAX_OPTIONS) {
|
||||
TextButton(onClick = { options = options + "" }) {
|
||||
Text("Add an option")
|
||||
}
|
||||
}
|
||||
// Said before the poll is asked, not after, because it is what
|
||||
// somebody is deciding on.
|
||||
Text(
|
||||
when {
|
||||
members < 2 -> "A poll needs somebody else in the group."
|
||||
members == 2 ->
|
||||
"In a group of two this is not anonymous: the other " +
|
||||
"person can work out your vote from the result. " +
|
||||
"It is still a poll."
|
||||
|
||||
else ->
|
||||
"Nobody is sent anybody's vote, not even you as the " +
|
||||
"person asking. Everyone other than the voter would " +
|
||||
"have to work together to know how one person voted. " +
|
||||
"Everyone has to vote before there is a result."
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = ready,
|
||||
onClick = {
|
||||
model.askPoll(group, question.trim(), filled)
|
||||
onDismiss()
|
||||
},
|
||||
modifier = Modifier.testTag("poll-ask"),
|
||||
) { Text("Ask") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
Spacer(Modifier.width(0.dp))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.E2ESession
|
||||
import io.flamenet.e2e.Envelope
|
||||
import io.flamenet.e2e.PeerBundle
|
||||
import io.flamenet.e2e.PollBook
|
||||
import io.flamenet.e2e.SealedSender
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
@@ -137,6 +138,78 @@ class Conversations(
|
||||
*/
|
||||
var requireSealed: () -> Boolean = { false }
|
||||
|
||||
// ---- Polls (§6.29) ----------------------------------------------------.
|
||||
|
||||
/** Polls in flight, restored from the vault the first time they are asked for. */
|
||||
val polls: PollBook by lazy {
|
||||
PollBook().apply { vault.loadPolls()?.let { restore(it) } }
|
||||
}
|
||||
|
||||
private fun savePolls() = vault.savePolls(polls.archive())
|
||||
|
||||
/**
|
||||
* Ask a poll of a group. Nothing is voted here: the poll goes to the group
|
||||
* and every member, this one included, votes separately.
|
||||
*/
|
||||
fun askPoll(
|
||||
group: RelayClient.Group,
|
||||
myUserId: Long,
|
||||
question: String,
|
||||
options: List<String>,
|
||||
): String {
|
||||
val members = (group.memberIds + myUserId).distinct().sorted()
|
||||
require(members.size >= 2) { "a poll needs somebody else in the group" }
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
val set = PollBook.memberSet(members)
|
||||
val body = Content.poll(id, question, options, group.id, set)
|
||||
polls.remember(id, group.id, question, options, set)
|
||||
savePolls()
|
||||
for (member in members) {
|
||||
if (member == myUserId) continue
|
||||
runCatching { sendPlaintext(member, body) }
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Vote, which means handing every member one piece of the vote and keeping
|
||||
* one. No member is ever sent the vote itself.
|
||||
*/
|
||||
fun vote(
|
||||
pollId: String,
|
||||
myUserId: Long,
|
||||
choice: Int,
|
||||
) {
|
||||
val entry = polls.poll(pollId) ?: return
|
||||
val outgoing = polls.vote(pollId, myUserId, choice)
|
||||
savePolls()
|
||||
for ((member, piece) in outgoing) {
|
||||
// One envelope per member, each different. This is the one place a
|
||||
// group message is not the same message to everybody: sending the
|
||||
// same piece to all of them would let any member subtract it from
|
||||
// the tally and read the vote.
|
||||
runCatching { sendPlaintext(member, Content.pollShare(pollId, piece, entry.groupId)) }
|
||||
}
|
||||
publishPollSum(pollId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish this device's sum, once every member has voted and never before:
|
||||
* a sum published early leaves noise in the total rather than a smaller
|
||||
* count.
|
||||
*/
|
||||
private fun publishPollSum(pollId: String) {
|
||||
val me = myUserId ?: return
|
||||
val entry = polls.poll(pollId) ?: return
|
||||
val sum = polls.sumToPublish(pollId, me) ?: return
|
||||
savePolls()
|
||||
val body = Content.pollSum(pollId, sum, entry.groupId)
|
||||
for (member in entry.members) {
|
||||
if (member == me) continue
|
||||
runCatching { sendPlaintext(member, body) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendToGroup(
|
||||
group: RelayClient.Group,
|
||||
text: String,
|
||||
@@ -1469,6 +1542,37 @@ class Conversations(
|
||||
vault.saveDisappearSeconds(Vault.threadForUser(fromUser), seconds)
|
||||
}
|
||||
|
||||
Content.Kind.POLL -> {
|
||||
val raw = content.raw ?: return
|
||||
val opts = raw.optJSONArray("opts") ?: return
|
||||
val options = (0 until opts.length()).map { opts.optString(it) }
|
||||
if (polls.remember(
|
||||
raw.optString("pid"),
|
||||
raw.optString("gid"),
|
||||
raw.optString("q"),
|
||||
options,
|
||||
raw.optString("mset"),
|
||||
)
|
||||
) {
|
||||
savePolls()
|
||||
}
|
||||
}
|
||||
|
||||
Content.Kind.POLL_SHARE -> {
|
||||
val raw = content.raw ?: return
|
||||
val piece = Content.pollVector(content) ?: return
|
||||
if (polls.receivePiece(raw.optString("pid"), fromUser, piece)) {
|
||||
savePolls()
|
||||
publishPollSum(raw.optString("pid"))
|
||||
}
|
||||
}
|
||||
|
||||
Content.Kind.POLL_SUM -> {
|
||||
val raw = content.raw ?: return
|
||||
val sum = Content.pollVector(content) ?: return
|
||||
if (polls.receiveSum(raw.optString("pid"), fromUser, sum)) savePolls()
|
||||
}
|
||||
|
||||
Content.Kind.OFF_RECORD -> {
|
||||
val on = Content.offTheRecord(content) ?: return
|
||||
vault.saveOffTheRecord(Vault.threadForUser(fromUser), on)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A share goes to one member. Sending the same one to the group reveals the
|
||||
* vote (§6.29).
|
||||
*
|
||||
* Every other group envelope in the app is the same bytes to everybody, so the
|
||||
* group send is the obvious thing to reach for, and it is the one call that
|
||||
* must not appear in the vote path: handing every member the same piece means
|
||||
* any of them can subtract it from the tally, and the poll is anonymous in name
|
||||
* only. The mistake leaves no trace at runtime. The counts still come out
|
||||
* right.
|
||||
*
|
||||
* `Conversations` needs a relay, so this reads the source, bounded to the
|
||||
* function each check is about.
|
||||
*/
|
||||
class PollSharesGoToOneMemberTest {
|
||||
private val source =
|
||||
File("src/main/kotlin/io/flamenet/client/Conversations.kt").readText()
|
||||
|
||||
/** From a declaration to the next one at the same indent. */
|
||||
private fun function(marker: String): String {
|
||||
val start = source.indexOf(marker)
|
||||
assertTrue(start >= 0, "missing $marker")
|
||||
val rest = source.substring(start + marker.length)
|
||||
val end =
|
||||
listOf("\n fun ", "\n private fun ", "\n // ----")
|
||||
.mapNotNull { rest.indexOf(it).takeIf { i -> i >= 0 } }
|
||||
.minOrNull() ?: rest.length
|
||||
return rest.substring(0, end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voting sends each piece to the member it belongs to`() {
|
||||
val vote = function("fun vote(")
|
||||
assertTrue(
|
||||
vote.contains("for ((member, piece) in outgoing)"),
|
||||
"each piece must be addressed to its own member",
|
||||
)
|
||||
assertTrue(vote.contains("sendPlaintext(member, Content.pollShare("))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `and never sends one to the whole group`() {
|
||||
val vote = function("fun vote(")
|
||||
assertFalse(
|
||||
vote.contains("sendToGroup"),
|
||||
"sending a share to the group hands every member the same piece, and any one " +
|
||||
"of them can then subtract it from the tally to read the vote",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The published sum is the same to everybody, which is the other half of
|
||||
* the rule: if it ever became a per-member calculation it would mean
|
||||
* somebody had copied the share path without knowing why it differed.
|
||||
*/
|
||||
@Test
|
||||
fun `the sum is the same envelope to every member`() {
|
||||
val publish = function("private fun publishPollSum(")
|
||||
assertTrue(publish.contains("val body = Content.pollSum("))
|
||||
assertTrue(publish.contains("for (member in entry.members)"))
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum published before the last vote arrives is noise in the total, not
|
||||
* a smaller count, so the decision stays with the book.
|
||||
*/
|
||||
@Test
|
||||
fun `publishing waits for the book to say so`() {
|
||||
assertTrue(function("private fun publishPollSum(").contains("polls.sumToPublish("))
|
||||
}
|
||||
|
||||
/** And a poll that is not written down could never finish. */
|
||||
@Test
|
||||
fun `every change to a poll is written down`() {
|
||||
assertTrue(function("fun askPoll(").contains("savePolls()"))
|
||||
assertTrue(function("fun vote(").contains("savePolls()"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import java.util.Random
|
||||
|
||||
/**
|
||||
* Every poll this device knows about, and what it owes each one (§6.29).
|
||||
*
|
||||
* Held apart from the session because the sequencing is the hard part and it is
|
||||
* worth being able to test it without a relay: a vote is split into pieces, one
|
||||
* per member, each member adds up the pieces they were handed, and only those
|
||||
* sums are published. Nothing here sends anything. It says what should be sent
|
||||
* and the session does it.
|
||||
*
|
||||
* The same shape as the iOS `PollBook`, deliberately. A poll crosses between
|
||||
* the two engines, so a rule enforced on one side and not the other is a poll
|
||||
* that tallies differently depending on who is looking at it.
|
||||
*/
|
||||
class PollBook {
|
||||
/** What a poll is waiting for, which is what the screen has to say. */
|
||||
sealed interface Standing {
|
||||
/**
|
||||
* Not everybody has voted yet. Until they have, nobody holds a complete
|
||||
* set of pieces and no sum can be published.
|
||||
*/
|
||||
data class WaitingForVotes(val from: List<Long>) : Standing
|
||||
|
||||
/** Everybody voted; not everybody has published their sum yet. */
|
||||
data class WaitingForSums(val from: List<Long>) : Standing
|
||||
|
||||
/** Every member published. These are the counts. */
|
||||
data class Counted(val counts: IntArray) : Standing {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Counted && counts.contentEquals(other.counts)
|
||||
|
||||
override fun hashCode(): Int = counts.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
data class Entry(
|
||||
val id: String,
|
||||
val groupId: String,
|
||||
val question: String,
|
||||
val options: List<String>,
|
||||
/**
|
||||
* The member set the arithmetic is agreed over, in the order the pieces
|
||||
* are indexed. Every client must use this same order.
|
||||
*/
|
||||
val members: List<Long>,
|
||||
/** What this device chose, once it has. */
|
||||
var myChoice: Int? = null,
|
||||
/** One piece per voter, the piece that voter handed to me. */
|
||||
val piecesHeld: MutableMap<Long, IntArray> = mutableMapOf(),
|
||||
/** One published sum per member. */
|
||||
val sumsPublished: MutableMap<Long, IntArray> = mutableMapOf(),
|
||||
/**
|
||||
* Whether this device already published its own sum, so it is published
|
||||
* exactly once however many times the last piece arrives.
|
||||
*/
|
||||
var didPublish: Boolean = false,
|
||||
) {
|
||||
val standing: Standing
|
||||
get() {
|
||||
val missingVotes = members.filter { piecesHeld[it] == null }
|
||||
if (missingVotes.isNotEmpty()) return Standing.WaitingForVotes(missingVotes)
|
||||
val missingSums = members.filter { sumsPublished[it] == null }
|
||||
if (missingSums.isNotEmpty()) return Standing.WaitingForSums(missingSums)
|
||||
// Every member published, so this is the tally rather than a
|
||||
// partial one. A missing sum would leave noise here, not a
|
||||
// smaller count, which is why it is never shown before now.
|
||||
return Standing.Counted(
|
||||
Poll.tally(members.mapNotNull { sumsPublished[it] }, options.size),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val entries = LinkedHashMap<String, Entry>()
|
||||
|
||||
val polls: List<Entry> get() = entries.values.toList()
|
||||
|
||||
fun poll(id: String): Entry? = entries[id]
|
||||
|
||||
fun pollsIn(groupId: String): List<Entry> = entries.values.filter { it.groupId == groupId }
|
||||
|
||||
/**
|
||||
* A poll arrived, or this device is asking one. Returns false for one
|
||||
* already known, so a duplicate delivery changes nothing.
|
||||
*/
|
||||
fun remember(
|
||||
id: String,
|
||||
groupId: String,
|
||||
question: String,
|
||||
options: List<String>,
|
||||
memberSet: String,
|
||||
): Boolean {
|
||||
if (entries.containsKey(id)) return false
|
||||
val members = members(memberSet)
|
||||
if (members.size < 2 || options.size !in 2..Poll.MAX_OPTIONS) return false
|
||||
entries[id] = Entry(id, groupId, question, options, members)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A piece of somebody's vote reached this device. Returns whether it was
|
||||
* new, so a redelivery cannot be counted twice.
|
||||
*/
|
||||
fun receivePiece(
|
||||
id: String,
|
||||
from: Long,
|
||||
piece: IntArray,
|
||||
): Boolean {
|
||||
val entry = entries[id] ?: return false
|
||||
if (from !in entry.members) return false
|
||||
if (piece.size != entry.options.size) return false
|
||||
if (entry.piecesHeld.containsKey(from)) return false
|
||||
entry.piecesHeld[from] = piece
|
||||
return true
|
||||
}
|
||||
|
||||
/** A member published their sum. */
|
||||
fun receiveSum(
|
||||
id: String,
|
||||
from: Long,
|
||||
sum: IntArray,
|
||||
): Boolean {
|
||||
val entry = entries[id] ?: return false
|
||||
if (from !in entry.members) return false
|
||||
if (sum.size != entry.options.size) return false
|
||||
if (entry.sumsPublished.containsKey(from)) return false
|
||||
entry.sumsPublished[from] = sum
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* This device's vote, split into one piece per member. The piece for this
|
||||
* device is kept rather than sent, and the rest are addressed to the member
|
||||
* they belong to: a piece sent to the whole group would let any member
|
||||
* subtract it from the tally and read the vote.
|
||||
*/
|
||||
fun vote(
|
||||
id: String,
|
||||
me: Long,
|
||||
choice: Int,
|
||||
random: Random = SecureRandom(),
|
||||
): List<Pair<Long, IntArray>> {
|
||||
val entry = entries[id] ?: return emptyList()
|
||||
if (entry.myChoice != null) return emptyList()
|
||||
val pieces = Poll.shares(choice, entry.options.size, entry.members.size, random)
|
||||
entry.myChoice = choice
|
||||
val mine = entry.members.indexOf(me)
|
||||
if (mine >= 0) entry.piecesHeld[me] = pieces[mine]
|
||||
return entry.members.zip(pieces).filter { it.first != me }
|
||||
}
|
||||
|
||||
/**
|
||||
* The sum to publish, once every member has voted and not before, and only
|
||||
* once. Null when there is nothing to publish yet.
|
||||
*/
|
||||
fun sumToPublish(
|
||||
id: String,
|
||||
me: Long,
|
||||
): IntArray? {
|
||||
val entry = entries[id] ?: return null
|
||||
if (entry.didPublish) return null
|
||||
if (me !in entry.members) return null
|
||||
if (entry.members.any { entry.piecesHeld[it] == null }) return null
|
||||
val sum = Poll.partial(entry.members.mapNotNull { entry.piecesHeld[it] }, entry.options.size)
|
||||
entry.didPublish = true
|
||||
// Publishing is also receiving: this device's own sum counts toward the
|
||||
// tally like anybody else's.
|
||||
entry.sumsPublished[me] = sum
|
||||
return sum
|
||||
}
|
||||
|
||||
// ---- At rest ----------------------------------------------------------.
|
||||
|
||||
fun archive(): JSONObject {
|
||||
val root = JSONObject()
|
||||
for ((id, e) in entries) {
|
||||
val o = JSONObject()
|
||||
o.put("gid", e.groupId)
|
||||
o.put("q", e.question)
|
||||
o.put("opts", JSONArray(e.options))
|
||||
o.put("members", JSONArray(e.members))
|
||||
e.myChoice?.let { o.put("choice", it) }
|
||||
o.put("didPublish", e.didPublish)
|
||||
o.put("held", vectors(e.piecesHeld))
|
||||
o.put("sums", vectors(e.sumsPublished))
|
||||
root.put(id, o)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
fun restore(root: JSONObject) {
|
||||
entries.clear()
|
||||
for (id in root.keys()) {
|
||||
val o = root.optJSONObject(id) ?: continue
|
||||
val options = strings(o.optJSONArray("opts"))
|
||||
val members = longs(o.optJSONArray("members"))
|
||||
if (options.size < 2 || members.size < 2) continue
|
||||
val e =
|
||||
Entry(
|
||||
id = id,
|
||||
groupId = o.optString("gid"),
|
||||
question = o.optString("q"),
|
||||
options = options,
|
||||
members = members,
|
||||
myChoice = if (o.has("choice")) o.optInt("choice") else null,
|
||||
didPublish = o.optBoolean("didPublish"),
|
||||
)
|
||||
readVectors(o.optJSONObject("held"), e.piecesHeld)
|
||||
readVectors(o.optJSONObject("sums"), e.sumsPublished)
|
||||
entries[id] = e
|
||||
}
|
||||
}
|
||||
|
||||
private fun vectors(from: Map<Long, IntArray>): JSONObject {
|
||||
val o = JSONObject()
|
||||
for ((who, v) in from) o.put(who.toString(), JSONArray(v.toList()))
|
||||
return o
|
||||
}
|
||||
|
||||
private fun readVectors(
|
||||
from: JSONObject?,
|
||||
into: MutableMap<Long, IntArray>,
|
||||
) {
|
||||
val o = from ?: return
|
||||
for (key in o.keys()) {
|
||||
val who = key.toLongOrNull() ?: continue
|
||||
val a = o.optJSONArray(key) ?: continue
|
||||
into[who] = IntArray(a.length()) { a.optInt(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun strings(a: JSONArray?): List<String> =
|
||||
if (a == null) emptyList() else (0 until a.length()).map { a.optString(it) }
|
||||
|
||||
private fun longs(a: JSONArray?): List<Long> =
|
||||
if (a == null) emptyList() else (0 until a.length()).map { a.optLong(it) }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The member set as it travels: ids, ascending, comma separated. Both
|
||||
* engines must agree on this exactly, because it fixes which index each
|
||||
* member's piece sits at. Sorting is what makes two clients that learned
|
||||
* the membership in a different order still agree.
|
||||
*/
|
||||
fun memberSet(ids: List<Long>): String = ids.toSortedSet().joinToString(",")
|
||||
|
||||
fun members(set: String): List<Long> = set.split(",").mapNotNull { it.trim().toLongOrNull() }
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ class Vault(
|
||||
private const val STORY_ARCHIVE = "story-archive"
|
||||
private const val DISAPPEAR = "disappear"
|
||||
private const val OFF_RECORD = "offrecord"
|
||||
private const val POLLS = "polls"
|
||||
|
||||
/** Bridge lines contacts have offered, pending review. See [bridgeOffers]. */
|
||||
private const val BRIDGE_OFFERS = "bridge-offers"
|
||||
@@ -1017,6 +1018,17 @@ class Vault(
|
||||
fun disappearSeconds(threadId: String): Int = readJson(DISAPPEAR)?.optInt(threadId, 0) ?: 0
|
||||
|
||||
/** Whether this conversation is off the record (§6.28). */
|
||||
/**
|
||||
* Polls in flight (§6.29).
|
||||
*
|
||||
* Kept at rest, or a poll could never finish: it waits for every member to
|
||||
* vote and then to publish, and the pieces already gathered would be gone
|
||||
* the first time the app was closed.
|
||||
*/
|
||||
fun loadPolls(): JSONObject? = readJson(POLLS)
|
||||
|
||||
fun savePolls(polls: JSONObject) = writeJson(POLLS, polls)
|
||||
|
||||
fun offTheRecord(threadId: String): Boolean = readJson(OFF_RECORD)?.optBoolean(threadId, false) ?: false
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.util.Random
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Running a whole poll between three people, without a relay (§6.29).
|
||||
*
|
||||
* The sequencing is the part that goes wrong: a sum published before everybody
|
||||
* has voted is not a partial answer, it is noise, and a piece counted twice is
|
||||
* a wrong count that nothing else would catch.
|
||||
*/
|
||||
class PollBookTest {
|
||||
private val members = listOf(4L, 7L, 9L)
|
||||
|
||||
private fun book(): PollBook =
|
||||
PollBook().apply {
|
||||
remember("p1", "g1", "Lunch?", listOf("Thai", "Pizza"), PollBook.memberSet(members))
|
||||
}
|
||||
|
||||
private fun rng(seed: Long) = Random(seed)
|
||||
|
||||
/** Everybody votes, the pieces travel, everybody publishes. */
|
||||
@Test
|
||||
fun `a whole poll between three people`() {
|
||||
val books = members.associateWith { book() }
|
||||
val choices = mapOf(4L to 1, 7L to 0, 9L to 1)
|
||||
|
||||
for (voter in members) {
|
||||
val outgoing = books[voter]!!.vote("p1", voter, choices[voter]!!, rng(voter))
|
||||
assertEquals(members.size - 1, outgoing.size, "my own piece never leaves")
|
||||
for ((member, piece) in outgoing) {
|
||||
assertTrue(books[member]!!.receivePiece("p1", voter, piece))
|
||||
}
|
||||
}
|
||||
|
||||
val sums = members.associateWith { assertNotNull(books[it]!!.sumToPublish("p1", it)) }
|
||||
for ((publisher, sum) in sums) {
|
||||
for (member in members) {
|
||||
if (member != publisher) {
|
||||
assertTrue(books[member]!!.receiveSum("p1", publisher, sum))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (member in members) {
|
||||
val standing = books[member]!!.poll("p1")!!.standing
|
||||
assertTrue(standing is PollBook.Standing.Counted, "$member has no tally")
|
||||
assertContentEquals(
|
||||
intArrayOf(1, 2),
|
||||
(standing as PollBook.Standing.Counted).counts,
|
||||
"one Thai, two Pizza",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The rule the interface depends on. */
|
||||
@Test
|
||||
fun `no sum is published before everybody has voted`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
assertNull(mine.sumToPublish("p1", 4L), "only my own vote is in")
|
||||
|
||||
val other = book()
|
||||
val from7 = other.vote("p1", 7L, 1, rng(2))
|
||||
mine.receivePiece("p1", 7L, from7.first { it.first == 4L }.second)
|
||||
assertNull(mine.sumToPublish("p1", 4L), "still nothing from 9")
|
||||
|
||||
val third = book()
|
||||
val from9 = third.vote("p1", 9L, 1, rng(3))
|
||||
mine.receivePiece("p1", 9L, from9.first { it.first == 4L }.second)
|
||||
assertNotNull(mine.sumToPublish("p1", 4L), "now every member has voted")
|
||||
}
|
||||
|
||||
/** And it says who it is waiting on. */
|
||||
@Test
|
||||
fun `it says who it is waiting for`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
val standing = mine.poll("p1")!!.standing
|
||||
assertTrue(standing is PollBook.Standing.WaitingForVotes)
|
||||
assertEquals(listOf(7L, 9L), (standing as PollBook.Standing.WaitingForVotes).from)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sum is published only once`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
for (voter in listOf(7L, 9L)) {
|
||||
val out = book().vote("p1", voter, 1, rng(voter))
|
||||
mine.receivePiece("p1", voter, out.first { it.first == 4L }.second)
|
||||
}
|
||||
assertNotNull(mine.sumToPublish("p1", 4L))
|
||||
assertNull(mine.sumToPublish("p1", 4L), "a second publish would double the count")
|
||||
}
|
||||
|
||||
/** Redelivery is the failure that shows up as a tally quietly being wrong. */
|
||||
@Test
|
||||
fun `a redelivered piece is ignored`() {
|
||||
val out = book().vote("p1", 7L, 1, rng(2))
|
||||
val piece = out.first { it.first == 4L }.second
|
||||
val mine = book()
|
||||
assertTrue(mine.receivePiece("p1", 7L, piece))
|
||||
assertFalse(mine.receivePiece("p1", 7L, piece), "counted twice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redelivered sum is ignored`() {
|
||||
val mine = book()
|
||||
assertTrue(mine.receiveSum("p1", 7L, intArrayOf(3, -3)))
|
||||
assertFalse(mine.receiveSum("p1", 7L, intArrayOf(3, -3)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stranger cannot join the arithmetic`() {
|
||||
val mine = book()
|
||||
assertFalse(mine.receivePiece("p1", 999L, intArrayOf(1, 0)))
|
||||
assertFalse(mine.receiveSum("p1", 999L, intArrayOf(1, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a vector of the wrong width is refused`() {
|
||||
val mine = book()
|
||||
assertFalse(mine.receivePiece("p1", 7L, intArrayOf(1, 0, 0)))
|
||||
assertFalse(mine.receiveSum("p1", 7L, intArrayOf(1)))
|
||||
}
|
||||
|
||||
/** Both engines index the pieces the same way, whatever order was learned. */
|
||||
@Test
|
||||
fun `the member set is order independent`() {
|
||||
assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 4L, 7L)))
|
||||
assertEquals("4,7,9", PollBook.memberSet(listOf(9L, 9L, 4L, 7L)))
|
||||
assertEquals(listOf(4L, 7L, 9L), PollBook.members("4,7,9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redelivered poll does not reset it`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 0, rng(1))
|
||||
assertFalse(
|
||||
mine.remember(
|
||||
"p1",
|
||||
"g1",
|
||||
"Lunch?",
|
||||
listOf("Thai", "Pizza"),
|
||||
PollBook.memberSet(members),
|
||||
),
|
||||
)
|
||||
assertEquals(0, mine.poll("p1")!!.myChoice, "the vote survived")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voting twice changes nothing`() {
|
||||
val mine = book()
|
||||
val first = mine.vote("p1", 4L, 0, rng(1))
|
||||
val second = mine.vote("p1", 4L, 1, rng(1))
|
||||
assertTrue(first.isNotEmpty())
|
||||
assertTrue(second.isEmpty(), "a second vote must send nothing")
|
||||
assertEquals(0, mine.poll("p1")!!.myChoice)
|
||||
}
|
||||
|
||||
/**
|
||||
* A poll has to survive being closed, or it could never finish: it waits
|
||||
* for every member, and the pieces already gathered would be gone.
|
||||
*/
|
||||
@Test
|
||||
fun `it survives a round trip through storage`() {
|
||||
val mine = book()
|
||||
mine.vote("p1", 4L, 1, rng(1))
|
||||
val out = book().vote("p1", 7L, 0, rng(2))
|
||||
mine.receivePiece("p1", 7L, out.first { it.first == 4L }.second)
|
||||
|
||||
val back = PollBook()
|
||||
back.restore(JSONObject(mine.archive().toString()))
|
||||
|
||||
val e = assertNotNull(back.poll("p1"))
|
||||
assertEquals("Lunch?", e.question)
|
||||
assertEquals(listOf("Thai", "Pizza"), e.options)
|
||||
assertEquals(members, e.members)
|
||||
assertEquals(1, e.myChoice)
|
||||
assertContentEquals(
|
||||
mine.poll("p1")!!.piecesHeld[7L],
|
||||
e.piecesHeld[7L],
|
||||
"the piece somebody handed us has to come back exactly",
|
||||
)
|
||||
assertContentEquals(mine.poll("p1")!!.piecesHeld[4L], e.piecesHeld[4L])
|
||||
}
|
||||
|
||||
/** And the negative values in a share have to survive it too. */
|
||||
@Test
|
||||
fun `storage keeps the sign and the extremes`() {
|
||||
val mine = book()
|
||||
mine.receiveSum("p1", 7L, intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE))
|
||||
val back = PollBook()
|
||||
back.restore(JSONObject(mine.archive().toString()))
|
||||
assertContentEquals(
|
||||
intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE),
|
||||
back.poll("p1")!!.sumsPublished[7L],
|
||||
)
|
||||
}
|
||||
|
||||
/** One member is not a poll. */
|
||||
@Test
|
||||
fun `a poll needs at least two members`() {
|
||||
val b = PollBook()
|
||||
assertTrue(b.remember("p2", "g1", "?", listOf("a", "b"), "4,7"))
|
||||
assertFalse(b.remember("p3", "g1", "?", listOf("a", "b"), "4"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The poll envelopes have to come back out of the parser with their fields
|
||||
* intact, and must never reach a thread as a bubble (§6.29).
|
||||
*/
|
||||
class PollParseProbeTest {
|
||||
@Test
|
||||
fun `a poll parses with its raw fields`() {
|
||||
val wire = Content.poll("p1", "Lunch?", listOf("Thai", "Pizza"), "g1", "4,7,9")
|
||||
val c = Content.parse(wire)
|
||||
assertEquals(Content.Kind.POLL, c.kind)
|
||||
val raw = assertNotNull(c.raw, "a poll must keep its fields")
|
||||
assertEquals("p1", raw.optString("pid"))
|
||||
assertEquals("Lunch?", raw.optString("q"))
|
||||
assertEquals("4,7,9", raw.optString("mset"))
|
||||
assertEquals("g1", raw.optString("gid"))
|
||||
assertTrue(c.isControl, "a poll is consumed, never shown")
|
||||
assertFalse(c.isRenderable, "a poll must never become a bubble")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share and a sum parse with their vectors`() {
|
||||
val share = Content.parse(Content.pollShare("p1", intArrayOf(7, -7), "g1"))
|
||||
assertEquals(Content.Kind.POLL_SHARE, share.kind)
|
||||
assertNotNull(share.raw)
|
||||
assertEquals(listOf(7, -7), Content.pollVector(share)?.toList())
|
||||
assertFalse(share.isRenderable)
|
||||
|
||||
val sum = Content.parse(Content.pollSum("p1", intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE), "g1"))
|
||||
assertEquals(listOf(Int.MIN_VALUE, Int.MAX_VALUE), Content.pollVector(sum)?.toList())
|
||||
assertFalse(sum.isRenderable)
|
||||
}
|
||||
|
||||
/** An older build must ignore these rather than showing anything. */
|
||||
@Test
|
||||
fun `a poll is known to this build`() {
|
||||
assertTrue(Content.Kind.POLL in Content.Kind.KNOWN)
|
||||
assertTrue(Content.Kind.POLL_SHARE in Content.Kind.KNOWN)
|
||||
assertTrue(Content.Kind.POLL_SUM in Content.Kind.KNOWN)
|
||||
assertFalse(Content.parse(Content.poll("p1", "?", listOf("a", "b"), "g1", "1,2")).needsNewerClient)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user