feat(dialects): propose the ticked dialects as a wave
The FAB, the view model method behind it, and the navigation onto the session it opens. The picker can now do the thing it is for. One session per MAX_BATCH_SIZE entries, the way TranslationArtifactVersionDetailViewModel catches up a translation's missing chapters. The catalogue is smaller than one batch today so the chunking never fires; it is one line, it is the established idiom, and it is what stops the sixty-fifth catalogue entry from being a bug rather than a second session. The jvmTest exercises it anyway, with a synthetic selection of 65, because nothing else will. One timestamp for the lot, so a wave reads as the one act it is. The callback is invoked from Dispatchers.Main, and that is not decoration: it navigates, and navigating off Dispatchers.IO crashes. The handoff belongs in the view model rather than at the call site. A batch is all-or-nothing and a retry has to be a fresh proposal, since the items' nonce seeds have already been published against an aggregate. Both are answered by the rule the screen is built on -- it only ever proposes what is missing -- so asking again after a failed wave lands exactly the entries the failed one did not, which is what makes the signing screen right to say it is safe to ask. The new jvmTest runs the real proposal against a real database and a real key, because the half with the sharp edge is the one the caller cannot see: every event of a wave is re-authored under the group's key at the room's derivation path inside proposeSigningBatch, so the ids the group signs are not the ids dialectsOf produced. It also asserts the round trip the picker depends on -- a proposed wave parses back into the entries that were ticked, through the same fromDialectEvent arm applyInnerEvent will take -- and that missingFrom then stops offering them. Nothing was needed to make a wave readable: ProposedEvent already has its dialect arm, and WhatIsBeingSigned already leads a batch with "N events signed together" and refuses to let any of it be signed unless all of it parsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -41,6 +43,8 @@ import mantra.composeapp.generated.resources.already_in_this_group
|
||||
import mantra.composeapp.generated.resources.clear_the_selection
|
||||
import mantra.composeapp.generated.resources.nothing_matched_that_search
|
||||
import mantra.composeapp.generated.resources.pick_the_languages_this_group_will_translate_into
|
||||
import mantra.composeapp.generated.resources.propose_dialect
|
||||
import mantra.composeapp.generated.resources.propose_s_dialects
|
||||
import mantra.composeapp.generated.resources.s_of_s_selected
|
||||
import mantra.composeapp.generated.resources.search_by_language_country_or_code
|
||||
import mantra.composeapp.generated.resources.select_everything_missing
|
||||
@@ -55,6 +59,7 @@ import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.Decorative
|
||||
import press.mantra.compose.ui.composable.widgets.EmptyState
|
||||
@@ -63,6 +68,7 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
|
||||
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
import press.mantra.compose.ui.composable.widgets.buttons.ExtendedFab
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
import press.mantra.compose.ui.theme.MantraTheme
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
@@ -92,6 +98,7 @@ fun AddDialectsFromCatalogueScreen(
|
||||
chatRepository: ChatRepository,
|
||||
mantraRepository: MantraRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -130,6 +137,47 @@ fun AddDialectsFromCatalogueScreen(
|
||||
title = { Text(stringResource(Res.string.add_dialects_from_a_list)) },
|
||||
navigationIcon = { NavigateBackButton(onNavigateBack) },
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
// Only where there is something to propose. A room that
|
||||
// holds the whole catalogue gets the empty state and its
|
||||
// way to the form, and "Propose 0 dialects" underneath it
|
||||
// would be a button that can never do anything.
|
||||
if (state.missing.isNotEmpty()) {
|
||||
BottomAppBar(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ProposeWaveFab(
|
||||
count = viewModel.selected.size,
|
||||
enabled = state.canSign &&
|
||||
viewModel.selected.isNotEmpty(),
|
||||
onClick = {
|
||||
viewModel.proposeSelectedDialects(
|
||||
localChatRoom = state.localChatRoom,
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than
|
||||
// back to the group. Nothing
|
||||
// has been created yet -- the
|
||||
// dialects appear when enough
|
||||
// members sign -- so landing on
|
||||
// a list they are not in would
|
||||
// read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey =
|
||||
activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
@@ -222,6 +270,32 @@ fun AddDialectsFromCatalogueScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign what is ticked.
|
||||
*
|
||||
* Counted in the label because a wave is the thing being proposed and its size
|
||||
* is the whole of what distinguishes one from another -- a member who has
|
||||
* scrolled away from the summary still has to be told how many they are about to
|
||||
* put in front of the group.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProposeWaveFab(
|
||||
count: Int,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ExtendedFab(
|
||||
label = if (count == 1) {
|
||||
stringResource(Res.string.propose_dialect)
|
||||
} else {
|
||||
stringResource(Res.string.propose_s_dialects, count)
|
||||
},
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = Decorative) },
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How much is ticked, and the two ways to change all of it at once.
|
||||
*
|
||||
@@ -398,6 +472,7 @@ private fun AddDialectsFromCataloguePreview() {
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {},
|
||||
)
|
||||
@@ -425,6 +500,7 @@ private fun AddDialectsFromCatalogueNothingLeftPreview() {
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {},
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {},
|
||||
)
|
||||
|
||||
@@ -1252,6 +1252,15 @@ fun MantraNavHost(
|
||||
chatRepository = databaseChatRepository,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace this picker so back returns to the group rather
|
||||
// than to a selection whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<AddDialectsFromCatalogueRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(route = actionRoute)
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.language.AfricanLanguage
|
||||
import press.mantra.compose.managers.DialectScaffold
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
@@ -181,6 +184,83 @@ class AddDialectsFromCatalogueViewModel(
|
||||
return loaded.missing.filter { DialectScaffold.keyOf(it) in selected }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to sign the ticked dialects into existence.
|
||||
*
|
||||
* None of them are created here and none of them exist yet. What goes out is
|
||||
* a proposal, and the dialects appear -- on every member's device at once,
|
||||
* authored by the room's own key rather than by whoever ticked them -- when
|
||||
* enough members have signed.
|
||||
*
|
||||
* One session per [FrostSigningManager.MAX_BATCH_SIZE] entries, the same way
|
||||
* `TranslationArtifactVersionDetailViewModel` catches up a translation's
|
||||
* missing chapters. The catalogue is smaller than one batch today, so the
|
||||
* chunking never fires; it is a line, it is the established idiom, and it is
|
||||
* what stops the sixty-fifth catalogue entry from being a bug rather than a
|
||||
* second session. `DialectCatalogueSizeTest` is the tripwire.
|
||||
*
|
||||
* One timestamp for the lot, so a wave reads as the one act it is rather than
|
||||
* as entries that happen to share a minute.
|
||||
*
|
||||
* A batch is all-or-nothing: if one item cannot be aggregated the session
|
||||
* fails and none of them are applied, and a retry has to be a fresh proposal
|
||||
* because the items' nonce seeds have already been published. Both are
|
||||
* answered by the rule this screen is built on -- it only ever proposes what
|
||||
* is missing -- so asking again after a failed wave lands exactly the
|
||||
* entries the failed one did not, and the signing screen is right to tell a
|
||||
* member it is safe to ask.
|
||||
*/
|
||||
fun proposeSelectedDialects(
|
||||
localChatRoom: LocalChatRoom,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit,
|
||||
) {
|
||||
val languages = selectedLanguages()
|
||||
|
||||
if (languages.isEmpty()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val createdAt = TimeUtils.now()
|
||||
|
||||
val sessions = runCatching {
|
||||
languages.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { wave ->
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
events = DialectScaffold.dialectsOf(
|
||||
languages = wave,
|
||||
createdAt = createdAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a wave of dialects for signing", error)
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
// The first, because it is the one that opened; any others are in the
|
||||
// room's proposal list beside it.
|
||||
val session = sessions.firstOrNull()
|
||||
|
||||
if (session != null) selected.clear()
|
||||
|
||||
// On the main thread because the callback navigates, and navigating
|
||||
// off Dispatchers.IO crashes. The handoff belongs here rather than at
|
||||
// the call site.
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
if (session != null) onSuccess.invoke(session.id) else onFailure.invoke()
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AddDialectsFromCatalogueViewModel"
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.language.AfricanLanguage
|
||||
import press.mantra.compose.language.AfricanLanguages
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
|
||||
/**
|
||||
* What a wave of dialects actually puts in front of the group.
|
||||
*
|
||||
* `DialectScaffoldTest` builds the templates and asserts their shape, which is
|
||||
* the easy half. This runs the real proposal against a real database and a real
|
||||
* key, because the half with the sharp edge is the one the caller cannot see:
|
||||
* every event of a wave is re-authored under the group's key at the room's
|
||||
* derivation path, inside `proposeSigningBatch`, and the ids the group will sign
|
||||
* are not the ids `dialectsOf` produced.
|
||||
*
|
||||
* The wave is also the first batch in this app made of many *peers* -- fifty
|
||||
* dialects, no lead and no dependents -- rather than one thing and its parts.
|
||||
*/
|
||||
class DialectWaveProposalJvmTest {
|
||||
private val participants = 3
|
||||
private val threshold = 2
|
||||
|
||||
/** Stands in for a completed ceremony; the test is about the proposal, not the DKG. */
|
||||
private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = participants,
|
||||
threshold = threshold
|
||||
)
|
||||
|
||||
private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex()
|
||||
|
||||
private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey)
|
||||
|
||||
private val ceremonyId = "ceremony".padEnd(64, '0')
|
||||
private val members = listOf("a", "b", "c").map { it.repeat(64) }
|
||||
private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') }
|
||||
|
||||
private val proposer = members.first()
|
||||
|
||||
private lateinit var db: MantraDatabase
|
||||
private lateinit var room: LocalChatRoom
|
||||
|
||||
@AfterTest
|
||||
fun closeDatabase() {
|
||||
if (::db.isInitialized) db.close()
|
||||
}
|
||||
|
||||
/** The proposer's device: a room it can sign in, and the share to sign with. */
|
||||
private suspend fun openDevice() {
|
||||
db = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
|
||||
|
||||
val nostrEventId = "e$proposer".take(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = proposer,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128)
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId))
|
||||
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = proposer,
|
||||
subject = "#Ekklesia",
|
||||
description = SharedKeyDerivation.describe("Admins of the group."),
|
||||
mlsGroupState = null
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
|
||||
db.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = ceremonyId,
|
||||
chatRoomId = roomId,
|
||||
coordinatorPublicKey = proposer,
|
||||
userPublicKey = proposer,
|
||||
threshold = threshold,
|
||||
participantCount = participants,
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
hostPublicKey = hostKeys[0],
|
||||
round1Random = "1".repeat(64),
|
||||
round2AuxRandom = "2".repeat(64),
|
||||
thresholdPublicKey = thresholdPublicKey,
|
||||
secretShare = keyMaterial.secretShares[0].value.toHex(),
|
||||
publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() }
|
||||
)
|
||||
)
|
||||
|
||||
members.forEachIndexed { index, member ->
|
||||
db.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = ceremonyId,
|
||||
participantPublicKey = member,
|
||||
kind = DkgRitualEvents.HOST_KEY,
|
||||
payload = hostKeys[index]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
room = LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
/** Exactly what `AddDialectsFromCatalogueViewModel` proposes, for one wave. */
|
||||
private suspend fun proposeWave(languages: List<AfricanLanguage>, createdAt: Long = STAMP) =
|
||||
FrostSigningManager.proposeSigningBatch(
|
||||
database = db,
|
||||
localChatRoom = room,
|
||||
userPublicKey = proposer,
|
||||
events = DialectScaffold.dialectsOf(languages = languages, createdAt = createdAt),
|
||||
)
|
||||
|
||||
private suspend fun itemEvents(sessionId: String): List<Event> =
|
||||
db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) }
|
||||
|
||||
private fun dialectsOf(events: List<Event>) = events.map {
|
||||
DialectEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wave is one session with one item per entry, in order`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val wave = AfricanLanguages.ALL.take(5)
|
||||
val events = itemEvents(proposeWave(wave).id)
|
||||
|
||||
assertEquals(wave.size, events.size)
|
||||
assertTrue(events.all { it.kind == DialectEvent.KIND })
|
||||
assertEquals(wave.map { it.name }, dialectsOf(events).map { it.name() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every item carries the entry it came from`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val wave = AfricanLanguages.ALL.take(8)
|
||||
val dialects = dialectsOf(itemEvents(proposeWave(wave).id))
|
||||
|
||||
assertEquals(
|
||||
wave,
|
||||
dialects.map {
|
||||
AfricanLanguage(
|
||||
name = it.name().orEmpty(),
|
||||
code = it.language().orEmpty(),
|
||||
country = it.country().orEmpty(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole wave is authored by the room, not by whoever ticked it`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(4)).id)
|
||||
|
||||
// Signing runs at the path the room was derived at, so a dialect says
|
||||
// which group made it simply by being signed.
|
||||
assertTrue(events.all { it.pubKey == roomId })
|
||||
assertTrue(events.none { it.pubKey == proposer })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one wave is one timestamp`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(6)).id)
|
||||
|
||||
assertEquals(setOf(STAMP), events.map { it.createdAt }.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids the group will sign are not the ids the templates had, because the
|
||||
* author is resolved inside the proposal. Distinct all the same: the entries
|
||||
* share an author and a timestamp by construction, so their tags are the only
|
||||
* thing separating them.
|
||||
*/
|
||||
@Test
|
||||
fun `items of one wave get distinct ids`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeWave(AfricanLanguages.ALL.take(20)).id)
|
||||
|
||||
assertEquals(events.size, events.map { it.id }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the whole catalogue proposes in a single session`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val events = itemEvents(proposeWave(AfricanLanguages.ALL).id)
|
||||
|
||||
assertEquals(AfricanLanguages.ALL.size, events.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* A selection bigger than a batch is chunked rather than refused, which is
|
||||
* what the view model does with `chunked(MAX_BATCH_SIZE)`. Asserted here
|
||||
* because the catalogue is smaller than one batch today, so nothing else
|
||||
* exercises it.
|
||||
*/
|
||||
@Test
|
||||
fun `a selection larger than a batch becomes several sessions`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val oversized = (0 until FrostSigningManager.MAX_BATCH_SIZE + 1).map { index ->
|
||||
AfricanLanguage(name = "Language $index", code = "x$index", country = "Nowhere")
|
||||
}
|
||||
|
||||
val sessions = oversized.chunked(FrostSigningManager.MAX_BATCH_SIZE).map {
|
||||
proposeWave(it)
|
||||
}
|
||||
|
||||
assertEquals(2, sessions.size)
|
||||
assertEquals(
|
||||
listOf(FrostSigningManager.MAX_BATCH_SIZE, 1),
|
||||
sessions.map { itemEvents(it.id).size }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The round trip the picker depends on: the rows a signed wave becomes say
|
||||
* what was ticked.
|
||||
*
|
||||
* Against the *proposed* events rather than the templates, since those are
|
||||
* the ones the group signs -- and `fromDialectEvent` is the arm
|
||||
* `ChatMessage.applyInnerEvent` will take when it does.
|
||||
*/
|
||||
@Test
|
||||
fun `a proposed wave parses back into the dialects that were ticked`() = runBlocking {
|
||||
openDevice()
|
||||
|
||||
val wave = AfricanLanguages.ALL.take(12)
|
||||
val events = itemEvents(proposeWave(wave).id)
|
||||
|
||||
val rows = events.mapNotNull { event ->
|
||||
MantraDialect.fromDialectEvent(
|
||||
dialectEvent = DialectEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
),
|
||||
chatRoomId = roomId,
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(wave.size, rows.size)
|
||||
assertEquals(
|
||||
wave,
|
||||
rows.map { AfricanLanguage(name = it.name, code = it.language, country = it.country) }
|
||||
)
|
||||
|
||||
// And so the picker would now show every one of them as held rather than
|
||||
// offering them again.
|
||||
assertTrue(
|
||||
DialectScaffold.missingFrom(existing = rows, catalogue = wave).isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STAMP = 1_700_000_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user