feat(dialects): a dialect is a thing you can open

Artifacts, translations and projects each have a route, a state, a view model
and a screen. A dialect had a `ListItem` inside `ChatRoomDetailScreen`'s
`LazyColumn` with no `onClick`, which made it the one entity in a room a member
could look at and not open. This is that screen, and the card finally gets the
`onClick` it never had.

The two lists are the reason it exists. A dialect is a name, a language code and
a country; those three sit at the bottom under the details the way they do
everywhere else, and what a member opens a dialect to find out is what the group
has written in it. `MantraArtifact.dialectId` and
`MantraTranslationArtifactVersion.dialectId` are the only two columns in the app
that point at a dialect, and neither had a query.

Both new queries are scoped by the dialect alone, with no `chatRoomId` beside
it. `dialectId` is a foreign key into this room's own rows, so the key already
confines the read to one room and a second filter would be a second spelling of
a constraint that is already enforced. That is the one-room scope decision made
load-bearing rather than restated; when a dialect can be named across rooms --
Phase 9 of docs/project-feature.md -- these two queries are among the places
that have to learn about it, which is why the reasoning is on them rather than
here.

`Loaded` also carries `canAddSubgroup`, which Phase 3 needs and which is the
same two conditions `ChatRoomDetailUIState` already states: a Marmot room, and a
share of its key. It reads the room off the *dialect's* `chatRoomId` rather than
the route's, so the parent of a subgroup made here is the room the dialect
actually lives in even if a route is ever built by hand.

A missing dialect is `Error` and not an empty `Loaded`, because "this room has
written nothing in Sesotho" and "there is no such dialect here" would otherwise
look identical and only one of them is worth a retry button.

The test that earns its place is the two-dialects-in-one-room case: if either
query ever drifted to scoping by room, every single-dialect test would still
pass and that one would not.

Phase 2 of docs/subgroups-for-entities.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-10-03 01:19:22 +02:00
1 parent 153e92f558
commit 0727a04bae
12 files changed
+781 -2

No files matched your search

@@ -89,6 +89,7 @@
<string name="currently_no_messages_have_been_shared">Currently no messages have been shared.\nBreak the ice.</string>
<string name="delete_group">Delete group</string>
<string name="details">Details</string>
<string name="dialect_detail">Dialect detail</string>
<string name="dialect_name">Dialect name</string>
<string name="dialects">Dialects</string>
<string name="direct_message_detail">Direct message detail</string>
@@ -183,6 +184,8 @@
<string name="no_one_selected_yet">No one selected yet</string>
<string name="no_one_to_add_yet">No one to add yet.</string>
<string name="no_projects_exists_in_this_group">No projects exists in this group.</string>
<string name="no_artifacts_are_written_in_this_dialect">No artifacts are written in this dialect yet.</string>
<string name="no_translations_are_into_this_dialect">Nothing has been translated into this dialect yet.</string>
<string name="no_translations_yet">No translations yet.</string>
<string name="no_versions">No versions.</string>
<string name="not_now">Not now</string>
@@ -15,4 +15,17 @@ interface MantraArtifactDao {
@Query("SELECT * FROM MantraArtifact WHERE id = :id")
suspend fun getArtifactById(id: String): MantraArtifact?
/**
* The artifacts written in [dialectId], newest first.
*
* Scoped by the dialect alone and not also by a room. `dialectId` is a
* foreign key into this room's own rows, so it cannot reach another room's
* artifacts and a second `chatRoomId` filter would be a second spelling of a
* constraint the key already enforces. The day a dialect can be named across
* rooms -- see Phase 9 of docs/project-feature.md -- this is one of the
* places that has to learn about it.
*/
@Query("SELECT * FROM MantraArtifact WHERE dialectId = :dialectId ORDER BY createdAt DESC")
suspend fun getArtifactsByDialectId(dialectId: String): List<MantraArtifact>
}
@@ -41,4 +41,20 @@ interface MantraTranslationArtifactVersionDao {
@Query("SELECT * FROM MantraTranslationArtifactVersion WHERE id = :id")
suspend fun getTranslationById(id: String): MantraTranslationArtifactVersion?
/**
* The translations into [dialectId], newest first.
*
* Scoped by the dialect alone, for the reason
* `MantraArtifactDao.getArtifactsByDialectId` gives: the foreign key already
* confines it to one room.
*/
@Query(
"""
SELECT * FROM MantraTranslationArtifactVersion
WHERE dialectId = :dialectId
ORDER BY createdAt DESC
"""
)
suspend fun getTranslationsByDialectId(dialectId: String): List<MantraTranslationArtifactVersion>
}
@@ -104,6 +104,14 @@ class DatabaseMantraRepository(
override suspend fun getDialect(id: String): MantraDialect? =
database.mantraDialectDao().getDialectById(id)
override suspend fun getArtifactsForDialect(dialectId: String): List<MantraArtifact> =
database.mantraArtifactDao().getArtifactsByDialectId(dialectId)
override suspend fun getTranslationsForDialect(
dialectId: String
): List<MantraTranslationArtifactVersion> =
database.mantraTranslationArtifactVersionDao().getTranslationsByDialectId(dialectId)
override suspend fun addArtifactVersion(
localChatRoom: LocalChatRoom,
artifactId: HexKey,
@@ -57,6 +57,12 @@ interface MantraRepository {
suspend fun getDialect(id: String): MantraDialect?
/** The artifacts written in a dialect, newest first. */
suspend fun getArtifactsForDialect(dialectId: String): List<MantraArtifact>
/** The translations into a dialect, newest first. */
suspend fun getTranslationsForDialect(dialectId: String): List<MantraTranslationArtifactVersion>
suspend fun getProjects(chatRoomId: String): List<MantraProject>
suspend fun getProject(id: String): MantraProject?
@@ -128,6 +134,14 @@ interface MantraRepository {
override suspend fun getDialect(id: String): MantraDialect? = null
override suspend fun getArtifactsForDialect(
dialectId: String
): List<MantraArtifact> = emptyList()
override suspend fun getTranslationsForDialect(
dialectId: String
): List<MantraTranslationArtifactVersion> = emptyList()
override suspend fun getProjects(chatRoomId: String): List<MantraProject> = emptyList()
override suspend fun getProject(id: String): MantraProject? = null
@@ -105,6 +105,7 @@ import mantra.composeapp.generated.resources.subgroups
import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute
import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.managers.SubgroupManager
import mantra.composeapp.generated.resources.Res
@@ -566,12 +567,29 @@ fun ChatRoomDetailScreen(
items = chatRoomDetailUIState.dialects,
key = { dialect -> dialect.id }
) { dialect ->
Card {
Card(
onClick = {
onNavigateToRoute.invoke(
DialectDetailRoute(
activeUserPublicKey = activeUserPublicKey,
dialectId = dialect.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = "Dialect"
contentDescription = Decorative
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = Decorative
)
},
headlineContent = {
@@ -0,0 +1,317 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.country
import mantra.composeapp.generated.resources.details
import mantra.composeapp.generated.resources.dialect_detail
import mantra.composeapp.generated.resources.language
import mantra.composeapp.generated.resources.library
import mantra.composeapp.generated.resources.no_artifacts_are_written_in_this_dialect
import mantra.composeapp.generated.resources.no_translations_are_into_this_dialect
import mantra.composeapp.generated.resources.translations
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.DetailRow
import press.mantra.compose.ui.composable.widgets.EmptyState
import press.mantra.compose.ui.composable.widgets.ErrorState
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.theme.ConformancePreviews
import press.mantra.compose.ui.theme.MantraTheme
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.view.model.DialectDetailViewModel
import press.mantra.compose.ui.view.state.DialectDetailUIState
/**
* A dialect, and what the group has written in it.
*
* The two lists are the screen. A dialect is a name, a language code and a
* country, and those three are at the bottom under the details the way they are
* everywhere else; what a member opens a dialect for is the artifacts written in
* it and the translations into it, which are the only two things in this app that
* point at a dialect at all.
*
* Until this screen the dialect card in `ChatRoomDetailScreen` had no `onClick`,
* which made a dialect the one entity in a room a member could not open.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DialectDetailScreen(
activeUserPublicKey: HexKey,
dialectId: String,
chatRoomId: String,
relayHint: String?,
initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit,
onNavigateBack: () -> Unit,
) {
val dialectDetailViewModel: DialectDetailViewModel = viewModel(
factory = DialectDetailViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
dialectId = dialectId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialDialectDetailUIState = initialDialectDetailUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository,
)
)
ScreenStateTransition(dialectDetailViewModel.dialectDetailUIState) { uiState ->
when (val dialectDetailUIState = uiState) {
is DialectDetailUIState.Error -> ErrorState(
message = dialectDetailUIState.message,
// Reloading is worth offering even though the usual cause is a
// dialect this device does not hold: a room's rows arrive on the
// group's schedule, so the second attempt can genuinely find what
// the first could not.
onRetry = { dialectDetailViewModel.initiateDialectDetail() }
)
is DialectDetailUIState.Loaded -> {
val dialect = dialectDetailUIState.dialect
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(dialect.name) },
navigationIcon = { NavigateBackButton(onNavigateBack) },
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.fillMaxSize()
.padding(MaterialTheme.spacing.containerPadding),
verticalArrangement = Arrangement.spacedBy(
MaterialTheme.spacing.relatedGap
)
) {
item {
Text(
text = stringResource(Res.string.library),
style = MaterialTheme.typography.labelMedium
)
}
if (dialectDetailUIState.artifacts.isEmpty()) {
item {
EmptyState(
message = stringResource(
Res.string.no_artifacts_are_written_in_this_dialect
),
icon = Icons.Default.LibraryBooks,
)
}
} else {
items(
items = dialectDetailUIState.artifacts,
key = { artifact -> artifact.id }
) { artifact ->
Card(
onClick = {
onNavigateToRoute.invoke(
ArtifactDetailRoute(
activeUserPublicKey = activeUserPublicKey,
artifactId = artifact.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.LibraryBooks,
contentDescription = Decorative
)
},
headlineContent = { Text(artifact.name) },
supportingContent = { Text(artifact.url) }
)
}
}
}
item { HorizontalDivider() }
item {
Text(
text = stringResource(Res.string.translations),
style = MaterialTheme.typography.labelMedium
)
}
if (dialectDetailUIState.translations.isEmpty()) {
item {
EmptyState(
message = stringResource(
Res.string.no_translations_are_into_this_dialect
),
icon = Icons.Default.Translate,
)
}
} else {
items(
items = dialectDetailUIState.translations,
key = { translation -> translation.id }
) { translation ->
Card(
onClick = {
onNavigateToRoute.invoke(
TranslationArtifactVersionDetailRoute(
activeUserPublicKey = activeUserPublicKey,
translationArtifactVersionId = translation.id,
chatRoomId = chatRoomId,
relayHint = relayHint
)
)
}
) {
ListItem(
leadingContent = {
Icon(
Icons.Default.Translate,
contentDescription = Decorative
)
},
headlineContent = { Text(translation.name) },
supportingContent = { Text(translation.visibility) }
)
}
}
}
item { HorizontalDivider() }
item {
Text(
text = stringResource(Res.string.details),
style = MaterialTheme.typography.labelMedium
)
}
item {
Card {
// The pair that is actually the dialect's
// identity, and the name that is not: nothing in
// the schema makes a dialect unique, so two rooms
// naming `st`-in-Lesotho differently hold the same
// dialect under two names.
DetailRow(
label = stringResource(Res.string.language),
value = dialect.language
)
DetailRow(
label = stringResource(Res.string.country),
value = dialect.country
)
}
}
}
}
}
DialectDetailUIState.Loading -> {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.containerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
MaterialTheme.spacing.containerPadding
)
) {
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(Res.string.dialect_detail),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
LoadingDataIndicator(fillScreen = false)
Spacer(modifier = Modifier.weight(2f))
}
}
}
}
LaunchedEffect(true) {
if (initialDialectDetailUIState == DialectDetailUIState.Loading) {
dialectDetailViewModel.initiateDialectDetail()
}
}
}
@ConformancePreviews
@Composable
private fun DialectDetailScreenPreview() {
MantraTheme {
Surface(modifier = Modifier.fillMaxSize()) {
DialectDetailScreen(
activeUserPublicKey = "",
dialectId = "dialectId",
chatRoomId = "chatRoomId",
relayHint = null,
initialDialectDetailUIState = DialectDetailUIState.Loaded(
dialect = MantraDialect(
id = "dialectId",
publicKey = "author",
name = "Sesotho",
country = "Lesotho",
language = "st",
signature = "",
chatRoomId = "chatRoomId",
)
),
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {},
onNavigateBack = {}
)
}
}
}
@@ -151,6 +151,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ProjectDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChapterDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DialectDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslateChunkRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactVersionDetailRoute
@@ -165,6 +166,7 @@ import press.mantra.compose.ui.composable.FrostSigningScreen
import press.mantra.compose.ui.composable.ProposalListScreen
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.DialectDetailScreen
import press.mantra.compose.ui.composable.TranslateChunkScreen
import press.mantra.compose.ui.composable.TranslationChapterScreen
import press.mantra.compose.ui.composable.TranslationArtifactVersionDetailScreen
@@ -1326,6 +1328,26 @@ fun MantraNavHost(
}
)
}
composable<DialectDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DialectDetailRoute>()
DialectDetailScreen(
activeUserPublicKey = route.activeUserPublicKey,
dialectId = route.dialectId,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
mantraRepository = databaseMantraRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
},
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ProjectDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ProjectDetailRoute>()
@@ -0,0 +1,19 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* A dialect, and what is written in it.
*
* [chatRoomId] rides along although the dialect row already names its room,
* because every sibling detail route carries it and the screens pass it on to
* the routes they open. Reading it off the row instead would make this the one
* route that cannot be navigated to before its entity has loaded.
*/
@Serializable
data class DialectDetailRoute(
val activeUserPublicKey: String,
val dialectId: String,
val chatRoomId: String,
val relayHint: String? = null
): Route()
@@ -0,0 +1,99 @@
package press.mantra.compose.ui.view.model
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.ui.view.state.DialectDetailUIState
/**
* A dialect, what is written in it, and whether it could have a room of its own.
*
* The two lists are the reason this screen exists: a dialect is a name, a
* language code and a country, and the thing a member opens one to find out is
* what the group has written in it. Both are read by the dialect's id alone --
* see `MantraArtifactDao.getArtifactsByDialectId` for why no room is named
* beside it.
*/
class DialectDetailViewModel(
val dialectId: String,
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialDialectDetailUIState: DialectDetailUIState,
val mantraRepository: MantraRepository,
val chatRepository: ChatRepository,
): ViewModel() {
var dialectDetailUIState: DialectDetailUIState by mutableStateOf(initialDialectDetailUIState)
private set
private val logger = Logger.withTag(TAG)
fun initiateDialectDetail() {
logger.d("initiateDialectDetail: $dialectId")
viewModelScope.launch(Dispatchers.IO) {
val dialect = mantraRepository.getDialect(dialectId)
dialectDetailUIState = if (dialect == null) {
// Error rather than an empty `Loaded`, because the two are
// different answers: "this room has written nothing in Sesotho"
// and "there is no such dialect here" would otherwise look
// identical, and only one of them is worth retrying.
DialectDetailUIState.Error("Couldn't find the dialect")
} else {
// The dialect's own room, not the route's. They are the same
// room in every path that exists today, and reading it off the
// row is what keeps that true if a route is ever constructed by
// hand -- the parent of a subgroup made here must be the room
// the dialect actually lives in.
val room = chatRepository.getChatRoomByIdentifier(dialect.chatRoomId)
DialectDetailUIState.Loaded(
dialect = dialect,
artifacts = mantraRepository.getArtifactsForDialect(dialectId),
translations = mantraRepository.getTranslationsForDialect(dialectId),
canAddSubgroup = room?.chatRoom?.mlsGroupState != null &&
chatRepository.canSign(dialect.chatRoomId),
)
}
}
}
companion object {
private const val TAG = "DialectDetailViewModel"
fun factory(
activeUserPublicKey: HexKey,
dialectId: String,
chatRoomId: String,
relayHint: String?,
initialDialectDetailUIState: DialectDetailUIState = DialectDetailUIState.Loading,
mantraRepository: MantraRepository,
chatRepository: ChatRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
DialectDetailViewModel(
activeUserPublicKey = activeUserPublicKey,
dialectId = dialectId,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialDialectDetailUIState = initialDialectDetailUIState,
mantraRepository = mantraRepository,
chatRepository = chatRepository,
)
}
}
}
}
@@ -0,0 +1,47 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
sealed interface DialectDetailUIState {
data class Loaded(
val dialect: MantraDialect,
/**
* The artifacts written in this dialect.
*
* This and [translations] are what the screen is for. A dialect on its
* own is three strings -- a name, a language code and a country -- and a
* screen showing only those has no reason to exist. These are the two
* tables that carry a `dialectId`, and between them they answer the only
* question a member opens a dialect to ask.
*/
val artifacts: List<MantraArtifact> = emptyList(),
/** The translations into this dialect. */
val translations: List<MantraTranslationArtifactVersion> = emptyList(),
/**
* Whether this device could open a subgroup for this dialect at all.
*
* Two conditions, both about capability rather than permission, and the
* same two `ChatRoomDetailUIState.canAddSubgroup` states: the room has to
* be a Marmot one, since a NIP-17 room has no admins to be a parent's,
* and this device has to hold a share of the group's key, because
* certifying a child is a signature by the parent and
* `FrostSigningManager.proposeSigningBatch` throws for a device that
* cannot make one.
*
* The rest of the refusals are the picker's, where they can name who is
* missing and what to do about it.
*/
val canAddSubgroup: Boolean = false,
): DialectDetailUIState
data class Error(
val message: String
): DialectDetailUIState
data object Loading: DialectDetailUIState
}
@@ -0,0 +1,203 @@
package press.mantra.compose.ui.view.model
import androidx.room3.Room
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.MantraTranslationArtifactVersion
import press.mantra.compose.database.repository.DatabaseChatRepository
import press.mantra.compose.database.repository.DatabaseMantraRepository
import press.mantra.compose.ui.view.state.DialectDetailUIState
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* What a dialect's screen is looking at, and what it leaves out.
*
* The lists are read by the dialect's id alone -- no room is named beside it,
* because `dialectId` is a foreign key into this room's own rows and a second
* filter would restate a constraint the key already enforces. So the case worth
* having is a room holding *two* dialects: if the query ever drifted to scoping
* by room instead, every test with one dialect in it would still pass and this
* one would not.
*
* The other half is that a missing dialect is an error rather than an empty
* screen. "This room has written nothing in Sesotho" and "there is no such
* dialect here" would otherwise look identical, and only one of them is worth
* offering a retry for.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DialectDetailJvmTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
private val room = ProjectRoom(db)
@BeforeTest
fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher())
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
db.close()
}
private val scope = CoroutineScope(UnconfinedTestDispatcher())
private val otherDialectId = "9".repeat(64)
private val unusedDialectId = "d".repeat(64)
private fun viewModel(dialectId: String = room.dialectId) = DialectDetailViewModel(
dialectId = dialectId,
chatRoomId = room.roomId,
activeUserPublicKey = room.member,
relayHint = null,
initialDialectDetailUIState = DialectDetailUIState.Loading,
mantraRepository = DatabaseMantraRepository(db, scope),
chatRepository = DatabaseChatRepository(db, scope),
)
private suspend fun loaded(dialectId: String = room.dialectId): DialectDetailUIState.Loaded {
val viewModel = viewModel(dialectId)
viewModel.initiateDialectDetail()
return awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Loaded }
}
/** A second dialect in the same room, with its own artifact and translation. */
private suspend fun seedSecondDialect() {
db.mantraDialectDao().upsert(
MantraDialect(
id = otherDialectId,
publicKey = room.roomId,
name = "Setswana",
country = "BW",
language = "tn",
signature = room.signature,
chatRoomId = room.roomId,
)
)
db.mantraArtifactDao().upsert(
MantraArtifact(
id = "8".repeat(64),
publicKey = room.roomId,
name = "Another book",
url = "https://example.invalid/another",
visibility = "private",
dialectId = otherDialectId,
license = "cc",
chatRoomId = room.roomId,
signature = room.signature,
)
)
db.mantraTranslationArtifactVersionDao().upsert(
MantraTranslationArtifactVersion(
id = "4".repeat(64),
publicKey = room.roomId,
artifactVersionId = room.versionId,
dialectId = otherDialectId,
name = "Setswana Mockingbird",
visibility = "private",
license = "cc",
signature = room.signature,
chatRoomId = room.roomId,
)
)
}
@Test
fun `the dialect loads with what is written in it`() = runBlocking {
room.seed()
val state = loaded()
assertEquals("Sesotho", state.dialect.name)
assertEquals("st", state.dialect.language)
assertEquals(listOf(room.artifactId), state.artifacts.map { it.id })
assertEquals(listOf(room.translationId), state.translations.map { it.id })
}
/**
* The case that fails if the queries ever scope by room instead of by
* dialect. Both dialects are in one room, so a room-scoped read returns all
* four rows and reads as a working screen.
*/
@Test
fun `another dialect in the same room is left out`() = runBlocking {
room.seed()
seedSecondDialect()
val sesotho = loaded()
assertEquals(listOf(room.artifactId), sesotho.artifacts.map { it.id })
assertEquals(listOf(room.translationId), sesotho.translations.map { it.id })
val setswana = loaded(otherDialectId)
assertEquals(listOf("8".repeat(64)), setswana.artifacts.map { it.id })
assertEquals(listOf("4".repeat(64)), setswana.translations.map { it.id })
}
/**
* The empty state, which is a normal state and not a failure: a room defines
* a dialect before it has anything in it, and that is the whole point of
* being able to define one.
*/
@Test
fun `a dialect nothing is written in loads empty rather than failing`() = runBlocking {
room.seed()
db.mantraDialectDao().upsert(
MantraDialect(
id = unusedDialectId,
publicKey = room.roomId,
name = "isiZulu",
country = "ZA",
language = "zu",
signature = room.signature,
chatRoomId = room.roomId,
)
)
val state = loaded(unusedDialectId)
assertEquals("isiZulu", state.dialect.name)
assertTrue(state.artifacts.isEmpty())
assertTrue(state.translations.isEmpty())
}
@Test
fun `a dialect this device does not hold is an error, not an empty screen`() = runBlocking {
room.seed()
val viewModel = viewModel(dialectId = "c".repeat(64))
viewModel.initiateDialectDetail()
val state = awaitValue { viewModel.dialectDetailUIState as? DialectDetailUIState.Error }
assertEquals("Couldn't find the dialect", state.message)
}
/**
* The fixture's room has no MLS state, so no subgroup can be made from it --
* a NIP-17 room has no admins to be a parent's. The flag says so instead of
* the button failing when pressed.
*/
@Test
fun `a room with no group state cannot have a subgroup made for its dialect`() = runBlocking {
room.seed()
assertFalse(loaded().canAddSubgroup)
}
}