Anonymous polls: the envelopes #159
@@ -66,6 +66,18 @@ data class Content(
|
||||
val pins: String? = null,
|
||||
/** The sequence this story item belongs to (§6.23), if any. */
|
||||
val storySet: StorySet? = null,
|
||||
/** Which poll this envelope belongs to (§6.29), for all three of its kinds. */
|
||||
val pollId: String? = null,
|
||||
/** The question, on the poll itself. */
|
||||
val pollQuestion: String? = null,
|
||||
/** What may be chosen, in the order the counts come back in. */
|
||||
val pollOptions: List<String> = emptyList(),
|
||||
/**
|
||||
* The member set the arithmetic is agreed over, hashed. Shares split across
|
||||
* a different set do not add up, and that should be visible rather than
|
||||
* arriving as a wrong tally.
|
||||
*/
|
||||
val pollMemberSet: String? = null,
|
||||
) {
|
||||
/**
|
||||
* A sticker: an id, never an image.
|
||||
@@ -282,6 +294,24 @@ data class Content(
|
||||
*/
|
||||
const val PIN = "pin"
|
||||
|
||||
/**
|
||||
* A poll asked of a group (§6.29): the question, its options, and the
|
||||
* member set the arithmetic is agreed over.
|
||||
*/
|
||||
const val POLL = "poll"
|
||||
|
||||
/**
|
||||
* One voter's piece of their own vote, for one member. Sent to that
|
||||
* member and to nobody else, which is what makes a vote unattributable.
|
||||
*/
|
||||
const val POLL_SHARE = "pshare"
|
||||
|
||||
/**
|
||||
* What one member publishes: the sum of every piece handed to them.
|
||||
* Adding these gives the counts, and no vote was ever sent to anybody.
|
||||
*/
|
||||
const val POLL_SUM = "psum"
|
||||
|
||||
/**
|
||||
* Consumed, never shown.
|
||||
*/
|
||||
@@ -315,6 +345,10 @@ data class Content(
|
||||
CallSignal.OFFER,
|
||||
CallSignal.ANSWER,
|
||||
CallSignal.END,
|
||||
CallSignal.REACTION,
|
||||
POLL,
|
||||
POLL_SHARE,
|
||||
POLL_SUM,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -527,6 +561,52 @@ data class Content(
|
||||
about = Reference(messageId, author = 0, preview = ""),
|
||||
)
|
||||
|
||||
/**
|
||||
* A poll put to a group. [members] is the agreed member list, hashed,
|
||||
* so both sides can tell they are counting over the same set: shares
|
||||
* split across a different set would not add up, and the difference
|
||||
* should be visible rather than arriving as a wrong tally.
|
||||
*/
|
||||
fun poll(
|
||||
pollId: String,
|
||||
question: String,
|
||||
options: List<String>,
|
||||
groupId: String,
|
||||
members: String,
|
||||
): String {
|
||||
require(options.size in 2..Poll.MAX_OPTIONS) {
|
||||
"a poll offers 2..${Poll.MAX_OPTIONS} options, not ${options.size}"
|
||||
}
|
||||
val opts = options.joinToString(",", "[", "]") { quote(it) }
|
||||
return "{\"fnc\":1,\"kind\":${quote(Kind.POLL)},\"ctl\":1,\"pid\":${quote(pollId)}," +
|
||||
"\"q\":${quote(question)},\"opts\":$opts,\"mset\":${quote(members)}," +
|
||||
"\"gid\":${quote(groupId)}}"
|
||||
}
|
||||
|
||||
/** One piece of one vote, addressed to a single member. */
|
||||
fun pollShare(
|
||||
pollId: String,
|
||||
piece: IntArray,
|
||||
groupId: String,
|
||||
): String =
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.POLL_SHARE)},\"ctl\":1,\"pid\":${quote(pollId)}," +
|
||||
"\"v\":${piece.joinToString(",", "[", "]")},\"gid\":${quote(groupId)}}"
|
||||
|
||||
/** The sum one member publishes, once every piece has reached them. */
|
||||
fun pollSum(
|
||||
pollId: String,
|
||||
sum: IntArray,
|
||||
groupId: String,
|
||||
): String =
|
||||
"{\"fnc\":1,\"kind\":${quote(Kind.POLL_SUM)},\"ctl\":1,\"pid\":${quote(pollId)}," +
|
||||
"\"v\":${sum.joinToString(",", "[", "]")},\"gid\":${quote(groupId)}}"
|
||||
|
||||
/** The integer vector a share or a sum carries, or null if malformed. */
|
||||
fun pollVector(content: Content): IntArray? {
|
||||
val array = content.raw?.optJSONArray("v") ?: return null
|
||||
return IntArray(array.length()) { array.optInt(it) }
|
||||
}
|
||||
|
||||
fun callSignal(signal: CallSignal): String {
|
||||
val call = JSONObject().put("id", signal.callId)
|
||||
signal.sdp?.let { call.put("sdp", it) }
|
||||
@@ -774,6 +854,33 @@ data class Content(
|
||||
)
|
||||
}
|
||||
|
||||
// §6.29. A poll and its pieces are never bubbles, and they are not
|
||||
// in CONTROL: that check returns a bare envelope before this point,
|
||||
// and these carry structure worth reading.
|
||||
if (kind == Kind.POLL) {
|
||||
val opts = json.optJSONArray("opts")
|
||||
return Content(
|
||||
"",
|
||||
kind = kind,
|
||||
raw = json,
|
||||
groupId = groupId,
|
||||
pollId = json.optString("pid").takeIf { it.isNotEmpty() },
|
||||
pollQuestion = json.optString("q"),
|
||||
pollOptions =
|
||||
(0 until (opts?.length() ?: 0)).map { opts?.optString(it).orEmpty() },
|
||||
pollMemberSet = json.optString("mset").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
if (kind == Kind.POLL_SHARE || kind == Kind.POLL_SUM) {
|
||||
return Content(
|
||||
"",
|
||||
kind = kind,
|
||||
raw = json,
|
||||
groupId = groupId,
|
||||
pollId = json.optString("pid").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
// Control traffic.
|
||||
if (kind == Kind.AVATAR) {
|
||||
val avatar =
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The three envelopes a poll rides on (§6.29): the poll itself, one piece of
|
||||
* one vote, and the sum a member publishes.
|
||||
*/
|
||||
class PollEnvelopeTest {
|
||||
@Test
|
||||
fun `a poll carries its question, options and member set`() {
|
||||
val wire =
|
||||
Content.poll(
|
||||
pollId = "p-1",
|
||||
question = "Where are we eating?",
|
||||
options = listOf("Pizza", "Noodles", "Anywhere warm"),
|
||||
groupId = "g-1",
|
||||
members = "abc123",
|
||||
)
|
||||
val read = Content.parse(wire)
|
||||
assertEquals(Content.Kind.POLL, read.kind)
|
||||
assertEquals("p-1", read.pollId)
|
||||
assertEquals("Where are we eating?", read.pollQuestion)
|
||||
assertContentEquals(listOf("Pizza", "Noodles", "Anywhere warm"), read.pollOptions)
|
||||
assertEquals("abc123", read.pollMemberSet)
|
||||
assertEquals("g-1", read.groupId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a piece and a sum carry their vector back intact`() {
|
||||
val piece = intArrayOf(7, -1_900_000_000, 0, 2_100_000_000)
|
||||
val share = Content.parse(Content.pollShare("p-1", piece, "g-1"))
|
||||
assertEquals(Content.Kind.POLL_SHARE, share.kind)
|
||||
assertEquals("p-1", share.pollId)
|
||||
assertContentEquals(piece, Content.pollVector(share))
|
||||
|
||||
val sum = Content.parse(Content.pollSum("p-1", piece, "g-1"))
|
||||
assertEquals(Content.Kind.POLL_SUM, sum.kind)
|
||||
assertContentEquals(piece, Content.pollVector(sum))
|
||||
}
|
||||
|
||||
/** Quoting is the usual way a hand-built envelope breaks. */
|
||||
@Test
|
||||
fun `a question with quotes and a newline survives`() {
|
||||
val awkward = "Say \"yes\"?\nOr not"
|
||||
val read = Content.parse(Content.poll("p-2", awkward, listOf("a", "b"), "g-1", "m"))
|
||||
assertEquals(awkward, read.pollQuestion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `none of the three is ever a bubble`() {
|
||||
assertFalse(Content.parse(Content.poll("p-1", "q", listOf("a", "b"), "g", "m")).isRenderable)
|
||||
assertFalse(Content.parse(Content.pollShare("p-1", intArrayOf(1), "g")).isRenderable)
|
||||
assertFalse(Content.parse(Content.pollSum("p-1", intArrayOf(1), "g")).isRenderable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Known, so an envelope this build understands is not filed as traffic from
|
||||
* a future one. `CallSignal.REACTION` was in ACTIONS but never here.
|
||||
*/
|
||||
@Test
|
||||
fun `the kinds are ones this build knows`() {
|
||||
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)
|
||||
// Went into ACTIONS when call reactions were built and never here, so a
|
||||
// call reaction was filed as traffic from a future build.
|
||||
assertTrue(Content.CallSignal.REACTION in Content.Kind.KNOWN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marked as control traffic, which is what keeps all three out of the
|
||||
* thread: an older build consumes them instead of drawing empty bubbles.
|
||||
*/
|
||||
@Test
|
||||
fun `all three say they are control traffic`() {
|
||||
assertTrue(Content.parse(Content.poll("p", "q", listOf("a", "b"), "g", "m")).isControl)
|
||||
assertTrue(Content.parse(Content.pollShare("p", intArrayOf(1), "g")).isControl)
|
||||
assertTrue(Content.parse(Content.pollSum("p", intArrayOf(1), "g")).isControl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a poll refuses a shape it cannot count`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
Content.poll("p", "q", listOf("only one"), "g", "m")
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
Content.poll("p", "q", List(Poll.MAX_OPTIONS + 1) { "opt $it" }, "g", "m")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed vector reads as absent rather than as zeroes`() {
|
||||
assertEquals(null, Content.pollVector(Content.parse("""{"fnc":1,"kind":"psum","pid":"p"}""")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user