feat: sign a translation into the artifact instead of submitting one

Starting a translation no longer creates one. It opens a signing session over a
TranslationArtifactVersionEvent and a TranslationChapterEvent per chapter, and
the translation appears -- on every member's device at once, authored by the
room's shared key rather than by whoever picked the dialect -- when enough
members have signed. The same trade the dialects, artifacts and chapters made:
a submission says "I am putting this in front of the group" and the group's
only recourse afterwards is social, while a signature is the group saying it
and it takes a quorum to say. A translation is what the group's readers will
read the work as, so the second is the honest one.

**The chapters, in the same batch.** A translation with no chapters is one
nobody can start: a translated chunk hangs off a translation chapter, which
hangs off the translation. They travel as their own signed events for the same
reason the chapter's chunks do since 2e133dd -- a row that carries the group's
signature over its own id can be checked by anybody holding it, rather than
only by whoever re-derives it.

That makes this the second caller of `proposeSigningBatch`'s lead/dependents
form, and for exactly the reason the form exists. A translation chapter carries
the id of the translation it belongs to, and that id is a hash over the group's
key at the room's derivation path -- neither resolved until the proposal runs.
A caller computing it would be recomputing `signingPath`, the one input in this
protocol that must never come from a proposer. So the translation is built
first and handed to `AddTranslationArtifactVersionViewModel.translationChaptersOf`,
which lays the chapters out against it. An item naming a translation nobody
signed is not a mistake that can be made rather than one to be tested for.

The lead is item 0 and items apply in `itemIndex` order, which is what
MantraTranslationChapter's foreign key to MantraTranslationArtifactVersion
needs: what is referenced is signed first as well as named first.

**Only existing dialects.** The form's "New dialect" chip and its three fields
are gone, and with them the path that created a dialect on the way to using it.
A dialect is the group's too -- AddDialectScreen has proposed one for signing
since the dialects made this trade -- so minting one as a side effect of
translating would have put a dialect nobody agreed to underneath a translation
the group did. Nothing is selected to begin with, because picking a default
would be choosing the language of the work; the room's own detail screen is
where a missing dialect is asked for, and the form says so when there are none.

**The cost, in front of whoever is looking.** MAX_BATCH_SIZE is 64 and the
translation takes one place, so MAX_CHAPTERS_PER_TRANSLATION is 63. Unlike a
chapter's paragraphs this is not something the person at the screen can shorten
by splitting anything, so it is stated rather than advised: the count is shown
against the cap as chapters load, coloured when past it, and the form will not
propose -- because the alternative is an IllegalArgumentException after the
fact. The view model refuses independently; the screen is not what enforces it.

**What a chapter signed later does not reach.** A translation covers the
chapters that existed when it was proposed, and nothing scaffolds a translation
chapter for one signed into the artifact afterwards. That is not new -- the DAO
this replaces scaffolded once too -- but it is now said where somebody can act
on it, in the form's own line, rather than discovered as a chapter that cannot
be translated. Fixing it properly is its own change.

**What went away.** MantraDao.addTranslationArtifactVersion and its way up
through the repository, including the commented-out chunk scaffolding it had
been carrying. Nothing called it once the screen proposed instead, and leaving
a path that authors a translation under a member's key while the UI insists on
a quorum is the trap eb34c8e removed for chapters.

MantraRepository.addDialect and DatabaseMantraRepository.addDialect went with
it: the new-dialect path was their last caller, so what remained was a
member-authored dialect reachable from any screen that holds a MantraRepository.
MantraDao.addDialect stays, because MantraDaoJvmTest uses it as the worked
example for the `rumorOf` seam that addArtifact, addArtifactVersion and
saveTranslation still run through.

**The screens.** AddTranslationArtifactVersionScreen loads the artifact's
latest version, its chapters and canSign up front and disables the FAB when any
of them is missing, the way the dialect, artifact and chapter screens do; on
success it lands on the session rather than on an artifact the translation is
not in yet. FrostSigningScreen described both new kinds as "Event of kind
30306" and a run of "Event of kind 30308", which is a member being asked to
sign a translation they cannot read; it now reads the dialect name, visibility
and licence for the translation and the position for each chapter.

**Tests.** TranslationBatchProposalJvmTest runs the real proposal against a
real database over a real ceremony, which is where the sharp edge is: item
order, every chapter naming the translation as the group will author it, the
source chapter and position each stands in for, one timestamp across the batch,
and both ends of the cap -- 63 chapters proposes, 64 is refused and leaves no
session behind. Checked against broken implementations rather than only against
a working one: naming the artifact version instead of the signed translation,
taking the index from list position, and stamping the chapters off their own
clock are each caught.

If the first of those came apart the translation would still be signed and
every chapter would still verify -- against a translation id nobody has. They
would fail a foreign key on the way in and the translation would simply arrive
empty, which is the failure worth a database to catch.

392 jvmTest and 244 testDebugUnitTest pass, none of the existing ones edited.

Not covered: applyInnerEvent's upserts, which need a database no test here
stands up, and addTranslation itself, which is plumbing across two dispatchers
over a template and a builder the tests already pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 11:07:49 +02:00
parent 2e133dd337
commit 39e5df8253
9 changed files with 631 additions and 311 deletions

View File

@@ -11,16 +11,12 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.database.model.MantraTranslationChunk
import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.SubmissionEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
import press.mantra.compose.nostr.nip30303.tags.ArtifactIdTag
import press.mantra.compose.repository.MantraRepository.Companion.DEFAULT_LICENSE
@@ -196,113 +192,6 @@ abstract class MantraDao(
return mantraArtifactVersion
}
@Transaction
open suspend fun addTranslationArtifactVersion(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? {
val artifact = database.mantraArtifactDao().getArtifactById(artifactId) ?: return null
val version = database.mantraArtifactVersionDao()
.getArtifactVersionsByArtifactId(artifactId)
.firstOrNull() ?: return null
val dialect = database.mantraDialectDao().getDialectById(dialectId) ?: return null
// The translation is named after its dialect, and inherits the source
// artifact's visibility/license.
val translationVersionTemplate = TranslationArtifactVersionEvent.build(
artifactVersionId = version.id,
dialectId = dialectId,
name = dialect.name,
visibility = artifact.visibility,
license = artifact.license,
)
val translationVersion = MantraTranslationArtifactVersion.fromTranslationArtifactVersionEventTemplate(
translationArtifactVersionEventTemplate = translationVersionTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return null
return try {
database.mantraTranslationArtifactVersionDao().upsert(translationVersion)
submitToGroup(
chatRoomId = chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationVersionTemplate, userPublicKey),
text = "Added ${dialect.name} translation",
)
// Mirror the source structure: a translation chapter per chapter and
// an (untranslated) translation chunk per chunk.
val chapters = database.mantraChapterDao().getChaptersByArtifactVersionId(version.id)
chapters.forEach { chapter ->
val translationChapterTemplate = TranslationChapterEvent.build(
translationArtifactVersionId = translationVersion.id,
chapterId = chapter.id,
index = chapter.index,
)
val translationChapter = MantraTranslationChapter.fromTranslationChapterEventTemplate(
translationChapterEventTemplate = translationChapterTemplate,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey,
) ?: return@forEach
database.mantraTranslationChapterDao().upsert(translationChapter)
submitToGroup(
chatRoomId = chatRoomId,
submitterPublicKey = userPublicKey,
payload = rumorOf(translationChapterTemplate, userPublicKey),
text = "Prepared ${dialect.name} translation of the chapter ${chapter.name}",
)
// TODO: Figure out if we seriously need the scaffolding
// val chunks = database.mantraChunkDao().getChunksByChapterId(chapter.id)
// chunks.forEach { chunk ->
// val translationChunkTemplate = TranslationChunkEvent.build(
// translationChapterId = translationChapter.id,
// chunkId = chunk.id,
// index = chunk.index,
// text = "", // Untranslated scaffold; filled in during authoring.
// )
// val translationChunk = MantraTranslationChunk.fromTranslationChunkEventTemplate(
// translationChunkEventTemplate = translationChunkTemplate,
// chatRoomId = chatRoomId,
// userPublicKey = userPublicKey,
// ) ?: return@forEach
//
// database.mantraTranslationChunkDao().upsert(translationChunk)
// val translationChunkInnerEvent = MarmotInnerEvent(
// id = translationChunk.id,
// publicKey = translationChunk.publicKey,
// kind = TranslationChunkEvent.KIND,
// createdAt = translationChunk.createdAt,
// tags = translationChunkTemplate.tags,
// content = translationChunkTemplate.content,
// chatRoomId = translationChunk.chatRoomId,
// )
// database.marmotInnerEventDao().upsert(
// translationChunkInnerEvent
// )
//
// sendMarmotInnerEvent(
// chatRoomId = chatRoomId,
// userPublicKey = userPublicKey,
// text = "Prepared translation chunk ${chunk.index}",
// marmotInnerEvent = translationChunkInnerEvent
// )
// }
}
translationVersion
} catch (error: Throwable) {
logger.e("Failed to add translation for artifact $artifactId", error)
null
}
}
@Transaction
open suspend fun saveTranslation(
translationChapterId: String,

View File

@@ -42,20 +42,6 @@ class DatabaseMantraRepository(
override suspend fun getChaptersForArtifactVersion(artifactVersionId: String): List<MantraChapter> =
database.mantraChapterDao().getChaptersByArtifactVersionId(artifactVersionId)
override suspend fun addTranslationArtifactVersion(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? {
return database.mantraDao().addTranslationArtifactVersion(
artifactId = artifactId,
dialectId = dialectId,
chatRoomId = chatRoomId,
userPublicKey = userPublicKey
)
}
override suspend fun getChapter(id: String): MantraChapter? =
database.mantraChapterDao().getChapterById(id)
@@ -102,22 +88,6 @@ class DatabaseMantraRepository(
override suspend fun getDialect(id: String): MantraDialect? =
database.mantraDialectDao().getDialectById(id)
override suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
userPublicKey: HexKey,
): MantraDialect? {
return database.mantraDao().addDialect(
localChatRoom = localChatRoom,
name = name,
country = country,
language = language,
userPublicKey = userPublicKey
)
}
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,

View File

@@ -59,32 +59,10 @@ interface MantraRepository {
suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk>
/**
* Start a translation of the artifact into the given dialect: creates a
* MantraTranslationArtifactVersion for the artifact's latest version, and
* scaffolds a MantraTranslationChapter for each existing chapter plus a
* MantraTranslationChunk (empty text) for each existing chunk. Returns null
* when the artifact has no version to translate.
*/
suspend fun addTranslationArtifactVersion(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion?
suspend fun getDialects(chatRoomId: String): List<MantraDialect>
suspend fun getDialect(id: String): MantraDialect?
suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
userPublicKey: HexKey,
): MantraDialect?
suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
@@ -133,25 +111,10 @@ interface MantraRepository {
override suspend fun getTranslationChunks(translationChapterId: String): List<MantraTranslationChunk> = emptyList()
override suspend fun addTranslationArtifactVersion(
artifactId: String,
dialectId: String,
chatRoomId: String,
userPublicKey: HexKey,
): MantraTranslationArtifactVersion? = null
override suspend fun getDialects(chatRoomId: String): List<MantraDialect> = emptyList()
override suspend fun getDialect(id: String): MantraDialect? = null
override suspend fun addDialect(
localChatRoom: LocalChatRoom,
name: String,
country: String,
language: String,
userPublicKey: HexKey,
): MantraDialect? = null
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,

View File

@@ -9,25 +9,23 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -36,6 +34,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -43,10 +43,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
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.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -63,6 +67,8 @@ fun AddTranslationArtifactVersionScreen(
initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState = AddTranslationArtifactVersionUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
@@ -74,7 +80,8 @@ fun AddTranslationArtifactVersionScreen(
relayHint = relayHint,
initialAddTranslationArtifactVersionUIState = initialAddTranslationArtifactVersionUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
)
)
@@ -90,13 +97,32 @@ fun AddTranslationArtifactVersionScreen(
}
is AddTranslationArtifactVersionUIState.Loaded -> {
val dialectNameFieldState = rememberTextFieldState()
val dialectCountryFieldState = rememberTextFieldState()
val dialectLanguageFieldState = rememberTextFieldState()
// null = "New dialect" (show the create fields); otherwise reuse an
// existing dialect by id.
// A translation is into a dialect the group already signed into
// existence. Nothing is chosen to begin with: picking the first one
// for somebody would be choosing the language of the work.
var selectedDialectId: String? by remember { mutableStateOf(null) }
val selectedDialect = addTranslationUIState.dialects
.firstOrNull { dialect -> dialect.id == selectedDialectId }
// A translation hangs off a version, and the group has to be able to
// sign; without both there is nothing this screen can propose.
val artifactVersion = addTranslationUIState.artifactVersion
val chapterCount = addTranslationUIState.chapters.size
// The translation and a chapter apiece are signed in one session,
// and a session signs a bounded number of events. Past that the
// artifact cannot be translated in one go at all, which is worth
// saying here rather than after a failed propose.
val tooManyChapters = chapterCount > AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION
val canProposeTranslation = artifactVersion != null &&
selectedDialect != null &&
addTranslationUIState.canSign &&
!tooManyChapters
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
@@ -117,32 +143,61 @@ fun AddTranslationArtifactVersionScreen(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canProposeTranslation) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canProposeTranslation) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canProposeTranslation) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) {
return@ExtendedFloatingActionButton
}
addTranslationArtifactVersionViewModel.addTranslation(
localChatRoom = addTranslationUIState.localChatRoom,
existingDialectId = selectedDialectId,
dialectNameField = dialectNameFieldState,
dialectCountryField = dialectCountryFieldState,
dialectLanguageField = dialectLanguageFieldState,
onSuccess = {
onNavigateToRoute.invoke(
ArtifactDetailRoute(
artifact = addTranslationUIState.artifact,
artifactVersion = artifactVersion,
dialect = selectedDialect,
chapters = addTranslationUIState.chapters,
onSuccess = { sessionId ->
// Onto the session rather than back to
// the artifact. Nothing has been created
// yet -- the translation appears when
// enough members sign -- so landing on
// the list it is not in would read as a
// failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifactId,
chatRoomId = chatRoomId,
relayHint = relayHint
sessionId = sessionId
)
)
},
onFailure = {}
onFailure = {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Failed Translation")
)
}
)
}
) {
Icon(
Icons.Default.Add,
contentDescription = "Add translation"
Icons.Default.Translate,
contentDescription = "Propose translation"
)
Text("Add Translation")
Text("Propose Translation")
}
}
)
@@ -152,8 +207,45 @@ fun AddTranslationArtifactVersionScreen(
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (!addTranslationUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"translation into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (artifactVersion == null) {
Text(
text = "This artifact has no version for a translation to be of.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (tooManyChapters) {
Text(
text = "$chapterCount chapters is more than the group can sign in " +
"one go, so this artifact cannot be translated here. It signs " +
"at most " +
"${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " +
"chapters at a time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Text("Translate into which dialect?")
if (addTranslationUIState.dialects.isEmpty()) {
// The only way out of this screen, and it is not on it:
// a dialect is the group's too, and asking for one is
// its own quorum from the room's own detail screen.
Text(
text = "This group has no dialects yet. Add one from the group's " +
"details before translating anything into it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -165,64 +257,29 @@ fun AddTranslationArtifactVersionScreen(
label = { Text(dialect.name) }
)
}
FilterChip(
selected = selectedDialectId == null,
onClick = { selectedDialectId = null },
leadingIcon = {
Icon(
Icons.Default.Add,
contentDescription = "Create a new dialect"
)
},
label = { Text("New dialect") }
)
}
// Only collect new-dialect details when not reusing an existing one.
if (selectedDialectId == null) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = dialectNameFieldState,
leadingIcon = {
Icon(
Icons.Default.Title,
contentDescription = "Name of the dialect"
)
},
label = { Text("Dialect Name") },
placeholder = { Text("eg. Sesotho") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = dialectCountryFieldState,
leadingIcon = {
Icon(
Icons.Default.Public,
contentDescription = "Country of the dialect"
)
},
label = { Text("Country") },
placeholder = { Text("eg. Lesotho") },
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = dialectLanguageFieldState,
leadingIcon = {
Icon(
Icons.Default.Translate,
contentDescription = "Language of the dialect"
)
},
label = { Text("Language") },
placeholder = { Text("eg. st") },
)
}
Text(
text = "The artifact's chapters and chunks will be scaffolded for this translation.",
style = MaterialTheme.typography.labelMedium
// What the group is being asked to sign, counted before
// the tap rather than described after it: the chapters
// are the rest of the batch. The empty case is worth
// its own sentence, because a translation proposed now
// is not reached by chapters signed after it.
text = if (chapterCount == 0) {
"The artifact has no chapters yet, so the group would sign the " +
"translation on its own. Chapters added afterwards do not join it."
} else {
"The group signs the translation and " +
"$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"} " +
"of ${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " +
"together. Chunks are translated one at a time afterwards."
},
style = MaterialTheme.typography.labelMedium,
color = if (tooManyChapters) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.typography.labelMedium.color
}
)
}
}
@@ -284,10 +341,32 @@ private fun AddTranslationArtifactVersionScreenPreview() {
license = "cc",
chatRoomId = "chatRoomId",
signature = ""
)
),
dialects = listOf(
MantraDialect(
id = "dialectId",
publicKey = "author",
name = "Sesotho",
country = "Lesotho",
language = "st",
chatRoomId = "chatRoomId",
signature = ""
)
),
artifactVersion = MantraArtifactVersion(
id = "artifactVersionId",
artifactId = "artifactId",
publicKey = "author",
versionLabel = "1.0",
chatRoomId = "chatRoomId",
signature = ""
),
canSign = true,
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {},
onNavigateBack = {}
)

View File

@@ -54,6 +54,8 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.text.Markdown
@@ -403,6 +405,24 @@ private fun OneThingBeingSigned(event: Event) {
).joinToString(" · ")
}
TranslationArtifactVersionEvent.KIND -> "New translation" to TranslationArtifactVersionEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { translation ->
// Named after the dialect it is into, which is the whole of what is
// being decided: everything else in the batch follows from it.
listOfNotNull(translation.name(), translation.visibility(), translation.license())
.joinToString(" · ")
}
TranslationChapterEvent.KIND -> "Chapter of the translation" to TranslationChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { chapter ->
// A translation chapter is a place for translated chunks to hang
// off, so there is nothing in it to read -- only where in the work
// it sits. Counted from one, the way the chapter list reads.
chapter.index()?.let { "Chapter ${it + 1}" } ?: "Position unknown"
}
// The one thing a group signs that is about the group rather than about
// its work, and the only one a member sees before the room has done
// anything. Shown as the path and the ceremony rather than as the key:

View File

@@ -995,15 +995,21 @@ fun MantraNavHost(
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
// Replace this screen and the stale artifact detail beneath it
// so we land on a freshly-loaded detail showing the new translation.
navController.navigate(route = actionRoute) {
popUpTo<ArtifactDetailRoute> {
frostSigningRepository = databaseFrostSigningRepository,
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
// Replace this screen so back returns to the artifact rather
// than to a form whose proposal has already gone out.
navController.navigate(route = signingRoute) {
popUpTo<AddTranslationRoute> {
inclusive = true
}
}
},
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
},
onNavigateBack = {
navController.popBackStack()
}

View File

@@ -1,7 +1,5 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -12,12 +10,22 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.view.state.AddTranslationArtifactVersionUIState
@@ -29,6 +37,7 @@ class AddTranslationArtifactVersionViewModel(
initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState,
val mantraRepository: MantraRepository,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var addTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState by mutableStateOf(initialAddTranslationArtifactVersionUIState)
@@ -42,6 +51,10 @@ class AddTranslationArtifactVersionViewModel(
viewModelScope.launch(Dispatchers.IO) {
val artifact = mantraRepository.getArtifact(artifactId)
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
// Translations attach to an artifact version; use the latest one.
val artifactVersion = mantraRepository.getArtifactVersions(artifactId).firstOrNull()
addTranslationArtifactVersionUIState = if (artifact == null || localChatRoom == null) {
AddTranslationArtifactVersionUIState.Error("Couldn't find the artifact")
} else {
@@ -49,32 +62,55 @@ class AddTranslationArtifactVersionViewModel(
artifact = artifact,
localChatRoom = localChatRoom,
dialects = mantraRepository.getDialects(chatRoomId),
artifactVersion = artifactVersion,
chapters = artifactVersion
?.let { mantraRepository.getChaptersForArtifactVersion(it.id) }
.orEmpty(),
canSign = frostSigningRepository.canSign(chatRoomId),
)
}
}
}
/**
* Asks the group to sign a translation of the artifact, and a translation
* chapter for every chapter it has, in one session.
*
* Nothing is created here. What goes out is a proposal to sign the lot, and
* the translation and its chapters appear on every member's device at once,
* authored by this room's own key rather than by whoever picked the dialect,
* when enough members have signed. That author is the room's id: signing
* runs at the path the room was derived at, so a translation says which
* group's artifact it belongs to simply by being signed.
*
* That is the difference from submitting one. A submission says "I am
* putting this in front of the group" and the group's only recourse
* afterwards is social. A signature is the group saying it, and it takes a
* quorum to say. A translation is what the group's readers will read the
* artifact as, so the second is the honest one.
*
* The chapters travel with it rather than being scaffolded on each device,
* for the same reason the chapter's chunks do: a translation chapter that
* carries the group's signature can be checked by anybody holding it, and a
* translation with no chapters is one nobody can start translating. What it
* costs is [MAX_CHAPTERS_PER_TRANSLATION]: the batch is capped, the
* translation takes one of its places, and an artifact with more chapters
* than the rest cannot be translated in one session at all.
*/
fun addTranslation(
localChatRoom: LocalChatRoom,
existingDialectId: HexKey?,
dialectNameField: TextFieldState,
dialectCountryField: TextFieldState,
dialectLanguageField: TextFieldState,
onSuccess: () -> Unit,
artifact: MantraArtifact,
artifactVersion: MantraArtifactVersion,
dialect: MantraDialect,
chapters: List<MantraChapter>,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
val dialectName = dialectNameField.text.toString()
val dialectCountry = dialectCountryField.text.toString()
val dialectLanguage = dialectLanguageField.text.toString()
// When no existing dialect is selected, the new-dialect fields are required.
val creatingNewDialect = existingDialectId.isNullOrBlank()
val newDialectIncomplete = dialectName.isBlank() || dialectCountry.isBlank() || dialectLanguage.isBlank()
if (creatingNewDialect && newDialectIncomplete) {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
// The cap is the group's, not this screen's, and proposing past it
// throws rather than failing softly. The form says so before the tap;
// this is the check that has to hold when it does not.
if (chapters.size > MAX_CHAPTERS_PER_TRANSLATION) {
onFailure.invoke()
return
}
@@ -83,43 +119,41 @@ class AddTranslationArtifactVersionViewModel(
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val translation = runCatching {
// Reuse the selected dialect, or create a new one for the translation.
val dialectId = if (creatingNewDialect) {
mantraRepository.addDialect(
localChatRoom = localChatRoom,
name = dialectName,
country = dialectCountry,
language = dialectLanguage,
userPublicKey = activeUserPublicKey,
)?.id ?: return@runCatching null
} else {
existingDialectId
}
val translationEventTemplate = TranslationArtifactVersionEvent.build(
artifactVersionId = artifactVersion.id,
dialectId = dialect.id,
// The translation is named after its dialect, and inherits the
// source artifact's visibility and license: it is the same work
// in another tongue, not a new one to license afresh.
name = dialect.name,
visibility = artifact.visibility,
license = artifact.license,
)
mantraRepository.addTranslationArtifactVersion(
artifactId = artifactId,
dialectId = dialectId,
chatRoomId = chatRoomId,
val session = runCatching {
frostSigningRepository.proposeSigningBatch(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
lead = translationEventTemplate,
// A translation chapter names the translation it belongs to,
// and that id is a hash over the group's key at the room's
// path -- neither of which this screen knows or should. The
// translation comes back built, and the chapters are laid
// out against it.
dependents = { translation -> translationChaptersOf(translation, chapters) }
)
}.onFailure { error ->
logger.e("Failed to add translation", error)
logger.e("Failed to propose a translation for signing", error)
}.getOrNull()
if (translation != null) {
dialectNameField.clearText()
dialectCountryField.clearText()
dialectLanguageField.clearText()
if (session != null) {
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke()
onSuccess.invoke(session.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
onFailure.invoke()
}
}
isActionPending.value = false
@@ -129,6 +163,49 @@ class AddTranslationArtifactVersionViewModel(
companion object {
private const val TAG = "AddTranslationViewModel"
/**
* A translation chapter for each of [chapters], ready to be signed with
* the translation they hang off.
*
* A translation and its chapters go to the group as one batch, so these
* are built from the translation *after* it has been authored under the
* group's key -- [translation] is the unsigned event the session will
* sign, which is where the id each chapter carries comes from. Laying
* them out anywhere else would mean naming a translation id before one
* exists.
*
* They take the translation's own timestamp, so the batch reads as one
* act rather than as events that happen to share a session.
*
* Empty when the artifact has no chapters, which is a translation with
* nothing yet to translate -- the chapters signed into the artifact
* afterwards do not reach a translation proposed before them.
*/
fun translationChaptersOf(
translation: Event,
chapters: List<MantraChapter>,
): List<EventTemplate<TranslationChapterEvent>> = chapters.map { chapter ->
TranslationChapterEvent.build(
translationArtifactVersionId = translation.id,
chapterId = chapter.id,
// The source chapter's own position, not this list's: the two
// agree today, and only one of them is what the group signed.
index = chapter.index,
createdAt = translation.createdAt,
)
}
/**
* The most chapters an artifact can be translated with in one session.
*
* The translation is signed together with a chapter apiece, and a
* session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The
* translation is one of them, so the chapters get the rest. Unlike a
* chapter's paragraphs this is not something whoever is looking at the
* screen can shorten, so it is said plainly rather than as advice.
*/
const val MAX_CHAPTERS_PER_TRANSLATION: Int = FrostSigningManager.MAX_BATCH_SIZE - 1
fun factory(
activeUserPublicKey: HexKey,
artifactId: String,
@@ -136,7 +213,8 @@ class AddTranslationArtifactVersionViewModel(
relayHint: String?,
initialAddTranslationArtifactVersionUIState: AddTranslationArtifactVersionUIState = AddTranslationArtifactVersionUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddTranslationArtifactVersionViewModel(
@@ -146,7 +224,8 @@ class AddTranslationArtifactVersionViewModel(
relayHint = relayHint,
initialAddTranslationArtifactVersionUIState = initialAddTranslationArtifactVersionUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository,
)
}
}

View File

@@ -1,6 +1,8 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
@@ -8,7 +10,38 @@ sealed interface AddTranslationArtifactVersionUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
val artifact: MantraArtifact,
/**
* The dialects this room has signed into existence. A translation is
* into one of them and only one of them: an invented dialect is a
* dialect nobody agreed to, and the room already has a screen for
* asking the group for a new one.
*/
val dialects: List<MantraDialect> = emptyList(),
/**
* The version being translated: the artifact's latest.
*
* Null on an artifact that has none, which is one there is nothing to
* translate -- a translation is of a version rather than of an artifact.
*/
val artifactVersion: MantraArtifactVersion? = null,
/**
* The chapters of that version, which the translation is laid out
* against: a translation chapter each, signed in the same session.
*
* Held here rather than re-read when proposing, so that the count the
* form shows is the count the group is asked to sign.
*/
val chapters: List<MantraChapter> = emptyList(),
/**
* Whether the group holds a shared key. A translation is signed into
* existence now rather than submitted, so a group without one cannot
* start one here at all.
*/
val canSign: Boolean = false,
): AddTranslationArtifactVersionUIState
data class Error(

View File

@@ -0,0 +1,281 @@
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.assertFailsWith
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.MantraChapter
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.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel
/**
* What a translation proposal actually puts in front of the group.
*
* The same sharp edge `ChapterBatchProposalJvmTest` covers for a chapter and
* its chunks, one level up: a translation chapter carries the id of the
* translation it belongs to, and that id does not exist until the proposal
* authors the translation under the group's key at the room's derivation path.
* Both are resolved inside `proposeSigningBatch` and neither is knowable to the
* caller, which is why the chapters are built from what it hands back.
*
* If that came apart, the translation would still be signed and every chapter
* would still verify -- against a translation id nobody has. They would fail a
* foreign key on the way in and the translation would simply arrive with no
* chapters, which is a translation nobody can start.
*/
class TranslationBatchProposalJvmTest {
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()
/** The admin room: the group's key walked to the admin path, which is its id. */
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 val artifactVersionId = "b".repeat(64)
private val dialectId = "d".repeat(64)
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>())
// Profile hangs off a nostr event, and a room off a profile. Neither is
// anything this test is about; they are the foreign keys in the way.
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 = "#admins",
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)
}
/**
* The source chapters, as the artifact already holds them. Only the id and
* the position travel into the translation, so nothing else is furnished.
*/
private fun sourceChapters(count: Int): List<MantraChapter> = (0 until count).map { index ->
MantraChapter(
id = "c$index".padEnd(64, 'f'),
artifactVersionId = artifactVersionId,
publicKey = roomId,
name = "Chapter ${index + 1}",
originalText = "Paragraph ${index + 1}.",
index = index,
wordCount = 2,
characterCount = 13,
signature = "0".repeat(128),
chatRoomId = roomId
)
}
private val translationTemplate = TranslationArtifactVersionEvent.build(
artifactVersionId = artifactVersionId,
dialectId = dialectId,
name = "Sesotho",
visibility = "private",
license = "cc",
)
/** Exactly what `AddTranslationArtifactVersionViewModel.addTranslation` proposes. */
private suspend fun proposeTranslation(chapters: List<MantraChapter>) =
FrostSigningManager.proposeSigningBatch(
database = db,
localChatRoom = room,
userPublicKey = proposer,
lead = translationTemplate,
dependents = { translation ->
AddTranslationArtifactVersionViewModel.translationChaptersOf(translation, chapters)
}
)
private suspend fun itemEvents(sessionId: String): List<Event> =
db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) }
@Test
fun `the translation leads the batch and its chapters follow`() = runBlocking {
openDevice()
val events = itemEvents(proposeTranslation(sourceChapters(3)).id)
// Item order is apply order, and a translation chapter row whose
// translation does not exist yet is a foreign key violation. The
// translation has to be first.
assertEquals(4, events.size)
assertEquals(TranslationArtifactVersionEvent.KIND, events.first().kind)
assertTrue(events.drop(1).all { it.kind == TranslationChapterEvent.KIND })
}
@Test
fun `every chapter names the translation as the group will author it`() = runBlocking {
openDevice()
val events = itemEvents(proposeTranslation(sourceChapters(3)).id)
val translation = events.first()
// The claim the whole `lead`/`dependents` form exists for. The id here is
// a hash over the group's key at the room's path; a caller that built the
// chapters before proposing could not have known it.
val chapters = events.drop(1).map {
TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig)
}
assertTrue(chapters.all { it.translationArtifactVersionId() == translation.id })
}
@Test
fun `each chapter keeps the source chapter it stands in for, and its place`() = runBlocking {
openDevice()
val source = sourceChapters(3)
val events = itemEvents(proposeTranslation(source).id)
val chapters = events.drop(1).map {
TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig)
}
// A translation chapter is only ever a place to hang translated chunks
// off the right source chapter, in the right order. Lose either and the
// translation reads as some other book.
assertEquals(source.map { it.id }, chapters.map { it.chapterId() })
assertEquals(source.map { it.index }, chapters.map { it.index() })
}
@Test
fun `the whole batch is stamped and authored as one act by the room`() = runBlocking {
openDevice()
val events = itemEvents(proposeTranslation(sourceChapters(3)).id)
// Signing runs at the path the room was derived at, so the author of
// every item is the room's own id -- chapters included, which is what
// lets one be checked without being told which key to expect.
assertTrue(events.all { it.pubKey == roomId })
assertTrue(events.none { it.pubKey == proposer })
// And they carry the translation's timestamp rather than each reading
// the clock, so the batch is one act rather than events that happen to
// share a session.
assertEquals(listOf(events.first().createdAt), events.map { it.createdAt }.distinct())
}
@Test
fun `an artifact of the most chapters allowed still proposes`() = runBlocking {
openDevice()
val events = itemEvents(
proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE - 1)).id
)
// The arithmetic the form is built on: the translation takes one of the
// batch's places and the chapters get the rest. If this stopped being
// true, the screen would offer a translation the session then refuses.
assertEquals(
FrostSigningManager.MAX_BATCH_SIZE,
AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + 1
)
assertEquals(FrostSigningManager.MAX_BATCH_SIZE, events.size)
}
@Test
fun `an artifact of one chapter too many is refused`() = runBlocking {
openDevice()
// Refused here rather than truncated: a batch that quietly dropped its
// last chapters would sign a translation the group believes covers the
// whole work while the end of it can never be translated. The screen
// checks first so this is never what a member sees, but the screen is
// not what enforces it.
assertFailsWith<IllegalArgumentException> {
proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE))
}
assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom(roomId))
}
}