feat: let a translation ask for the chapters it is missing
A translation is scaffolded from both ends now -- the chapters that existed when it was proposed, and each chapter signed afterwards putting itself in. Neither end closes the gap on its own, and no snapshot taken at proposal time can. Two ways they miss each other. A chapter and a translation proposed at the same moment each read what exists when they are proposed, so neither sees the other and nothing retries. And a scaffolding session that fails to reach a quorum leaves nothing behind to try again with -- the chapter's id is spent, since a re-proposed chapter is a different one. So the translation's own screen says what it is missing and offers to ask for it. Above the chapter list rather than below, because a chapter that is not in a translation is invisible from a list of the ones that are: the whole failure is that nothing looks wrong. **Matched on the chapter named, not counted.** `chaptersMissingFrom` compares which source chapter each translation chapter stands for. A count would read a translation that is missing its second chapter but picked up its third as one missing its last, and would then scaffold the wrong chapter -- leaving the real gap open and a duplicate beside it. It also means running a catch-up on a translation that is already complete proposes nothing at all, rather than a second copy of every chapter under fresh ids. **More sessions rather than a cap.** The missing chapters are chunked at MAX_BATCH_SIZE, one session each. A translation far enough behind to need more than a batch holds is not one to refuse; it is one the group answers for more than once. They share a timestamp, so a catch-up reads as the one act it is. The screen lands on the first session -- the rest are beside it in the room's list -- and the card stays until a quorum arrives, which is honest: the chapters are still missing until then. **Tests.** Two more in TranslationScaffoldTest, both about the matching rather than the counting: a gap in the middle, and a complete translation being missing nothing. Checked against a broken implementation -- taking the missing chapters as the tail after a count passes on a translation that fell behind at the end, which is the easy case, and is caught by the gap. Not covered: `catchUpMissingChapters` itself, which is plumbing over the templates those tests pin and the batch API the jvm tests pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package press.mantra.compose.managers
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
|
||||
/**
|
||||
@@ -15,9 +16,10 @@ import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
*
|
||||
* Both ends of that join arrive on their own schedule -- a chapter is signed
|
||||
* into an artifact that already has translations, a translation is started on
|
||||
* an artifact that already has chapters -- so the same scaffolding has to be
|
||||
* built from either side. One function for both, because two copies of a cross
|
||||
* product is two chances to disagree about what a translation covers.
|
||||
* an artifact that already has chapters -- so the same scaffolding is built
|
||||
* from either side and, when the two miss each other, afterwards. One function
|
||||
* for all three, because three copies of a cross product is three chances to
|
||||
* disagree about what a translation covers.
|
||||
*/
|
||||
object TranslationScaffold {
|
||||
|
||||
@@ -62,13 +64,29 @@ object TranslationScaffold {
|
||||
TranslationChapterEvent.build(
|
||||
translationArtifactVersionId = translationArtifactVersionId,
|
||||
chapterId = chapter.id,
|
||||
// The source chapter's own position, never this list's:
|
||||
// a caller may hold a subset, and counting the list would
|
||||
// renumber the chapters it happens to be holding.
|
||||
// The source chapter's own position, never this list's: a
|
||||
// catch-up proposes a subset, so counting the list would
|
||||
// renumber the chapters it happens to be filling in.
|
||||
index = chapter.index,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The source chapters a translation has nothing for yet.
|
||||
*
|
||||
* Matched on the source chapter each translation chapter names rather than
|
||||
* on how many there are of each. A translation that is missing its second
|
||||
* chapter and has picked up its third is not a translation that is missing
|
||||
* its last one, and a count would say it was.
|
||||
*/
|
||||
fun chaptersMissingFrom(
|
||||
translationChapters: List<MantraTranslationChapter>,
|
||||
sourceChapters: List<MantraChapter>,
|
||||
): List<MantraChapter> {
|
||||
val covered = translationChapters.mapTo(mutableSetOf()) { it.chapterId }
|
||||
|
||||
return sourceChapters.filterNot { it.id in covered }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Article
|
||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -22,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -33,7 +35,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
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.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
|
||||
import press.mantra.compose.ui.composable.widgets.DetailRow
|
||||
@@ -51,6 +56,8 @@ fun TranslationArtifactVersionDetailScreen(
|
||||
relayHint: String?,
|
||||
initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState = TranslationArtifactVersionDetailUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRoute: (Route) -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
@@ -62,6 +69,8 @@ fun TranslationArtifactVersionDetailScreen(
|
||||
relayHint = relayHint,
|
||||
initialTranslationArtifactVersionDetailUIState = initialTranslationArtifactVersionDetailUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -97,6 +106,75 @@ fun TranslationArtifactVersionDetailScreen(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Chapters the source version has and this translation
|
||||
// does not. Above the list rather than after it: a chapter
|
||||
// that is not here is invisible from a list of the ones
|
||||
// that are.
|
||||
val localChatRoom = translationDetailUIState.localChatRoom
|
||||
val missingChapters = translationDetailUIState.missingChapters
|
||||
|
||||
if (missingChapters.isNotEmpty()) {
|
||||
item {
|
||||
Card {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Default.PlaylistAdd,
|
||||
contentDescription = "Chapters missing from this translation"
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
"${missingChapters.size} " +
|
||||
(if (missingChapters.size == 1) "chapter is" else "chapters are") +
|
||||
" not in this translation yet"
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!translationDetailUIState.canSign) {
|
||||
"This group has no shared key, so it cannot sign them in."
|
||||
} else {
|
||||
"They were signed into the artifact after this " +
|
||||
"translation. Ask the group to add them so they " +
|
||||
"can be translated."
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
TextButton(
|
||||
enabled = localChatRoom != null &&
|
||||
translationDetailUIState.canSign &&
|
||||
!translationDetailViewModel.isActionPending.value,
|
||||
onClick = {
|
||||
if (localChatRoom == null) return@TextButton
|
||||
|
||||
translationDetailViewModel.catchUpMissingChapters(
|
||||
localChatRoom = localChatRoom,
|
||||
missingChapters = missingChapters,
|
||||
onSuccess = { sessionId ->
|
||||
onNavigateToRoute.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = {}
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Propose")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
}
|
||||
|
||||
// Chapters with translation progress
|
||||
item {
|
||||
Text(
|
||||
@@ -212,6 +290,8 @@ private fun TranslationArtifactVersionDetailScreenPreview() {
|
||||
)
|
||||
),
|
||||
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRoute = {},
|
||||
onNavigateBack = {}
|
||||
)
|
||||
|
||||
@@ -932,6 +932,8 @@ fun MantraNavHost(
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
mantraRepository = databaseMantraRepository,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRoute = { actionRoute ->
|
||||
navController.navigate(
|
||||
route = actionRoute
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -10,9 +11,16 @@ 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.launch
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.managers.TranslationScaffold
|
||||
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.TranslationChapterProgress
|
||||
import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState
|
||||
@@ -24,6 +32,8 @@ class TranslationArtifactVersionDetailViewModel(
|
||||
val relayHint: String?,
|
||||
initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState,
|
||||
val mantraRepository: MantraRepository,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var translationDetailUIState: TranslationArtifactVersionDetailUIState by mutableStateOf(initialTranslationArtifactVersionDetailUIState)
|
||||
@@ -31,6 +41,8 @@ class TranslationArtifactVersionDetailViewModel(
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
fun initiateTranslationArtifactVersionDetail() {
|
||||
logger.d("initiateTranslationArtifactVersionDetail: $translationArtifactVersionId")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -39,23 +51,97 @@ class TranslationArtifactVersionDetailViewModel(
|
||||
translationDetailUIState = if (translation == null) {
|
||||
TranslationArtifactVersionDetailUIState.Error("Couldn't find the translation")
|
||||
} else {
|
||||
val chapters = mantraRepository.getTranslationChapters(translationArtifactVersionId)
|
||||
.map { chapter ->
|
||||
val chunks = mantraRepository.getTranslationChunks(chapter.id)
|
||||
TranslationChapterProgress(
|
||||
chapter = chapter,
|
||||
totalChunks = chunks.size,
|
||||
translatedChunks = chunks.count { it.text.isNotBlank() },
|
||||
)
|
||||
}
|
||||
val translationChapters = mantraRepository.getTranslationChapters(translationArtifactVersionId)
|
||||
val chapters = translationChapters.map { chapter ->
|
||||
val chunks = mantraRepository.getTranslationChunks(chapter.id)
|
||||
TranslationChapterProgress(
|
||||
chapter = chapter,
|
||||
totalChunks = chunks.size,
|
||||
translatedChunks = chunks.count { it.text.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
TranslationArtifactVersionDetailUIState.Loaded(
|
||||
translation = translation,
|
||||
chapters = chapters,
|
||||
localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId),
|
||||
missingChapters = TranslationScaffold.chaptersMissingFrom(
|
||||
translationChapters = translationChapters,
|
||||
sourceChapters = mantraRepository
|
||||
.getChaptersForArtifactVersion(translation.artifactVersionId),
|
||||
),
|
||||
canSign = frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the group to put the chapters this translation is missing into it.
|
||||
*
|
||||
* The scaffolding a chapter is signed with covers the translations that
|
||||
* existed when it was proposed, and the scaffolding a translation is signed
|
||||
* with covers the chapters that existed when *it* was proposed. Two things
|
||||
* proposed at the same moment see neither the other, and a session that
|
||||
* fails to reach a quorum leaves nothing behind to try again with. Neither
|
||||
* is a bug that can be designed out of a snapshot taken at proposal time --
|
||||
* so this is the pass that reconciles them, run when somebody notices.
|
||||
*
|
||||
* It proposes only what is missing, matched on the source chapter each
|
||||
* translation chapter names, so running it on a translation that is already
|
||||
* complete proposes nothing rather than a second copy of everything.
|
||||
*/
|
||||
fun catchUpMissingChapters(
|
||||
localChatRoom: LocalChatRoom,
|
||||
missingChapters: List<MantraChapter>,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
if (missingChapters.isEmpty()) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against double submits from repeated taps.
|
||||
if (isActionPending.value) return
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// One timestamp for the lot, so a catch-up reads as the one act it
|
||||
// is rather than as chapters that happen to share a minute.
|
||||
val createdAt = TimeUtils.now()
|
||||
|
||||
val sessions = runCatching {
|
||||
// One session per MAX_BATCH_SIZE chapters. A translation far
|
||||
// enough behind to need more than a batch holds is not one to
|
||||
// refuse; it is one the group answers for more than once.
|
||||
missingChapters.chunked(FrostSigningManager.MAX_BATCH_SIZE).mapNotNull { batch ->
|
||||
frostSigningRepository.proposeSigningBatch(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
events = TranslationScaffold.chaptersOf(
|
||||
translationArtifactVersionIds = listOf(translationArtifactVersionId),
|
||||
chapters = batch.map(TranslationScaffold.SourceChapter::of),
|
||||
createdAt = createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose this translation's missing chapters", error)
|
||||
}.getOrNull().orEmpty()
|
||||
|
||||
// The first, because it is the one that opened; the rest are in the
|
||||
// room's session list beside it.
|
||||
val session = sessions.firstOrNull()
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
if (session != null) onSuccess.invoke(session.id) else onFailure.invoke()
|
||||
}
|
||||
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TranslationArtifactVersionDetailViewModel"
|
||||
|
||||
@@ -66,6 +152,8 @@ class TranslationArtifactVersionDetailViewModel(
|
||||
relayHint: String?,
|
||||
initialTranslationArtifactVersionDetailUIState: TranslationArtifactVersionDetailUIState = TranslationArtifactVersionDetailUIState.Loading,
|
||||
mantraRepository: MantraRepository,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
TranslationArtifactVersionDetailViewModel(
|
||||
@@ -75,6 +163,8 @@ class TranslationArtifactVersionDetailViewModel(
|
||||
relayHint = relayHint,
|
||||
initialTranslationArtifactVersionDetailUIState = initialTranslationArtifactVersionDetailUIState,
|
||||
mantraRepository = mantraRepository,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
|
||||
import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
|
||||
/** A translation chapter with how many of its chunks have been translated. */
|
||||
data class TranslationChapterProgress(
|
||||
@@ -14,6 +16,28 @@ sealed interface TranslationArtifactVersionDetailUIState {
|
||||
data class Loaded(
|
||||
val translation: MantraTranslationArtifactVersion,
|
||||
val chapters: List<TranslationChapterProgress> = emptyList(),
|
||||
|
||||
/**
|
||||
* The room this translation belongs to, which is who would sign the
|
||||
* chapters it is missing. Null when it cannot be read, which is a
|
||||
* translation nothing can be proposed for.
|
||||
*/
|
||||
val localChatRoom: LocalChatRoom? = null,
|
||||
|
||||
/**
|
||||
* Chapters of the source version this translation has nothing for.
|
||||
*
|
||||
* A translation covers the chapters that existed when it was proposed,
|
||||
* and a chapter signed afterwards is scaffolded into it by a session of
|
||||
* that chapter's own -- which the two can miss, by being proposed at the
|
||||
* same moment or by one of them not reaching a quorum. This is what is
|
||||
* left over when they do, and it is shown rather than inferred because
|
||||
* a chapter nobody can translate is invisible from the translation.
|
||||
*/
|
||||
val missingChapters: List<MantraChapter> = emptyList(),
|
||||
|
||||
/** Whether the group holds a shared key, without which nothing can be proposed. */
|
||||
val canSign: Boolean = false,
|
||||
): TranslationArtifactVersionDetailUIState
|
||||
|
||||
data class Error(
|
||||
|
||||
@@ -4,6 +4,7 @@ import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.database.model.MantraChapter
|
||||
import press.mantra.compose.database.model.MantraTranslationChapter
|
||||
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag
|
||||
import press.mantra.compose.nostr.nip30303.tags.IndexTag
|
||||
@@ -12,10 +13,11 @@ import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag
|
||||
/**
|
||||
* The join between a translation and the chapters it is of, as a pure function.
|
||||
*
|
||||
* Both ends arrive on their own schedule, so this is built from two
|
||||
* directions -- a translation being proposed, and a chapter being proposed.
|
||||
* What it must not do is disagree with itself about what a translation covers
|
||||
* depending on which direction it was built from.
|
||||
* Both ends arrive on their own schedule, so this is built from three
|
||||
* directions -- a translation being proposed, a chapter being proposed, and a
|
||||
* catch-up filling in what the two missed. What it must not do is disagree with
|
||||
* itself about what a translation covers depending on which direction it was
|
||||
* built from.
|
||||
*/
|
||||
class TranslationScaffoldTest {
|
||||
|
||||
@@ -35,6 +37,17 @@ class TranslationScaffoldTest {
|
||||
chatRoomId = "r".repeat(64),
|
||||
)
|
||||
|
||||
private fun translationChapterFor(chapter: MantraChapter, translationId: String) =
|
||||
MantraTranslationChapter(
|
||||
id = "t${chapter.id}".take(64),
|
||||
publicKey = "p".repeat(64),
|
||||
chapterId = chapter.id,
|
||||
translationArtifactVersionId = translationId,
|
||||
index = chapter.index,
|
||||
signature = "",
|
||||
chatRoomId = "r".repeat(64),
|
||||
)
|
||||
|
||||
private fun parse(templates: List<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<TranslationChapterEvent>>) =
|
||||
templates.map { template ->
|
||||
Triple(
|
||||
@@ -90,10 +103,10 @@ class TranslationScaffoldTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chapter keeps its own position, not its position in the list`() {
|
||||
fun `a catch-up keeps each chapter's own position, not the position in the gap`() {
|
||||
val chapters = (0..5).map(::sourceChapter)
|
||||
|
||||
// A caller may hold a subset, and the subset is not contiguous.
|
||||
// A catch-up proposes a subset, and the subset is not contiguous.
|
||||
// Counting the list would renumber chapters 3 and 5 as 1 and 2, which
|
||||
// is a translation that reads in a different order than the work.
|
||||
val templates = TranslationScaffold.chaptersOf(
|
||||
@@ -136,4 +149,35 @@ class TranslationScaffoldTest {
|
||||
).isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what is missing is matched on the chapter named, not on how many there are`() {
|
||||
val chapters = (0..3).map(::sourceChapter)
|
||||
|
||||
// Chapter 1 never landed but chapter 2 did, which a count would read as
|
||||
// "the last one is missing" and scaffold the wrong chapter.
|
||||
val missing = TranslationScaffold.chaptersMissingFrom(
|
||||
translationChapters = listOf(chapters[0], chapters[2]).map {
|
||||
translationChapterFor(it, translationA)
|
||||
},
|
||||
sourceChapters = chapters,
|
||||
)
|
||||
|
||||
assertEquals(listOf(chapters[1].id, chapters[3].id), missing.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a translation that is already complete is missing nothing`() {
|
||||
val chapters = (0..2).map(::sourceChapter)
|
||||
|
||||
// So that running a catch-up twice proposes nothing the second time,
|
||||
// rather than a second copy of every chapter under fresh ids.
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
TranslationScaffold.chaptersMissingFrom(
|
||||
translationChapters = chapters.map { translationChapterFor(it, translationA) },
|
||||
sourceChapters = chapters,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user