feat(dialects): work out what a room is missing from the catalogue

DialectScaffold.missingFrom and .dialectsOf, built as a deliberate parallel to
TranslationScaffold -- same package, same shape, same reason. "What is this room
missing" gets asked by the picker, again when a wave comes back, and a third time
by whatever reconciles the two; three copies would be three chances to disagree.

Matching is on (code, country) and never the name, which is the rule the eleven
tests exist to pin. A room that typed "Southern Sotho" where the catalogue says
"Sesotho" still holds st-in-Lesotho, and proposing it again would make a
duplicate that nothing can merge -- the two would not even collide, since their
ids differ by createdAt.

Normalisation is trim + lowercase(), the no-argument form on purpose: it is
locale-invariant in Kotlin common, so a device set to Turkish does not decide
that TI and ti are different languages.

dialectsOf takes one createdAt for the whole wave rather than reading the clock
per event, as TranslationScaffold.chaptersOf does, so a wave reads as the one act
it is. That also makes its ids a function of its inputs, which is what lets the
test assert the round trip back through fromDialectEventTemplate: what the picker
chose is what the row says once the group has signed it.

Pure and takes everything it reads, so it is testable without a database and a
composable can ask it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 22:36:38 +02:00
parent 9d34a2cc99
commit 08dc412594
2 changed files with 290 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.language.AfricanLanguage
import press.mantra.compose.language.AfricanLanguages
import press.mantra.compose.nostr.nip30303.DialectEvent
/**
* Which catalogue languages a room has no dialect for, and the events to propose
* them with.
*
* Built as a deliberate parallel to [TranslationScaffold], for the same reason:
* the question "what is this room missing" is asked from the picker, answered
* again when a wave comes back, and will be asked a third time by whatever
* reconciles the two. Three copies of it would be three chances to disagree
* about what a room already holds.
*
* Pure, and takes everything it reads. There is no repository here because the
* answer has to be computable from a list of rows and a list of open proposals,
* which is what makes it testable without a database and what lets a composable
* ask it.
*/
object DialectScaffold {
/**
* What makes two dialects the same one: the language code and the country.
*
* Not the name. Nothing in the schema makes a dialect unique -- its id is a
* content hash that includes `createdAt`, there is no index, and the rows are
* signed by a quorum so they cannot be rewritten -- so duplication is
* prevented here or not at all, and of the three fields the name is the only
* one that is a matter of spelling. A member typing "Sesotho" and a catalogue
* saying "Sesotho" agree until one of them carries a diacritic.
*/
data class Key(val code: String, val country: String)
/**
* [code] and [country] as a [Key], trimmed and lowercased.
*
* `lowercase()` without a locale, deliberately: the no-argument form is
* locale-invariant in Kotlin common, so a device set to Turkish does not
* decide that `TI` and `ti` are different languages.
*/
fun keyOf(code: String, country: String): Key = Key(
code = code.trim().lowercase(),
country = country.trim().lowercase(),
)
fun keyOf(language: AfricanLanguage): Key = keyOf(language.code, language.country)
/** The dialect's `language` column is its code -- see [DialectEvent]'s language tag. */
fun keyOf(dialect: MantraDialect): Key = keyOf(dialect.language, dialect.country)
/**
* The catalogue entries this room has no dialect for and none in flight, in
* catalogue order.
*
* [pending] is what an open signing session is already signing, which the
* caller works out with [pendingIn]. Leaving it empty is not a safe default
* dressed as one -- it is the right answer for a caller that only wants to
* know what the *rows* say, and the wrong one for a picker, where two admins
* both seeing the same forty missing is how a room ends up with eighty
* dialects that nothing will ever merge.
*/
fun missingFrom(
existing: List<MantraDialect>,
pending: List<AfricanLanguage> = emptyList(),
catalogue: List<AfricanLanguage> = AfricanLanguages.ALL,
): List<AfricanLanguage> {
val held = existing.mapTo(mutableSetOf(), ::keyOf) + pending.map(::keyOf)
return catalogue.filterNot { keyOf(it) in held }
}
/**
* One dialect event per entry, in the order given.
*
* [createdAt] is stamped on all of them rather than read per event, for the
* reason [TranslationScaffold.chaptersOf] does it: a wave proposed as one
* act should read as one act, not as fifty dialects that happen to share a
* minute. It also makes the wave's event ids a function of its inputs, which
* is what makes a batch assertable.
*
* `DialectEvent.build`'s `language` parameter is the ISO code, not the name
* -- the event's language tag has always held the code, and the form's
* placeholder says so.
*/
fun dialectsOf(
languages: List<AfricanLanguage>,
createdAt: Long,
): List<EventTemplate<DialectEvent>> = languages.map { language ->
DialectEvent.build(
name = language.name,
country = language.country,
language = language.code,
createdAt = createdAt,
)
}
}

View File

@@ -0,0 +1,190 @@
package press.mantra.compose.managers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.language.AfricanLanguage
import press.mantra.compose.language.AfricanLanguages
import press.mantra.compose.nostr.nip30303.DialectEvent
/**
* What a room is missing from the catalogue, as a pure function.
*
* Every assertion here is about the identity rule, because that rule is the only
* thing standing between this feature and a room full of duplicate dialects.
* Nothing in the schema makes a dialect unique, and two proposals of the same
* one do not even collide -- their ids differ by `createdAt` -- so a match this
* function misses is a duplicate nobody will ever merge.
*/
class DialectScaffoldTest {
private val chatRoomId = "r".repeat(64)
private fun dialect(
name: String,
code: String,
country: String,
) = MantraDialect(
id = "$name$code$country".padEnd(64, 'f').take(64),
publicKey = "p".repeat(64),
name = name,
country = country,
language = code,
signature = "",
chatRoomId = chatRoomId,
)
private val sesotho = AfricanLanguage("Sesotho", "st", "Lesotho")
private val swahiliKenya = AfricanLanguage("Kiswahili", "sw", "Kenya")
private val swahiliTanzania = AfricanLanguage("Kiswahili", "sw", "Tanzania")
private val catalogue = listOf(sesotho, swahiliKenya, swahiliTanzania)
@Test
fun aRoomWithNoDialectsIsMissingTheWholeCatalogue() {
assertEquals(
AfricanLanguages.ALL,
DialectScaffold.missingFrom(existing = emptyList())
)
}
@Test
fun aHeldDialectIsNotMissingAndTheRestStillAre() {
assertEquals(
listOf(swahiliKenya, swahiliTanzania),
DialectScaffold.missingFrom(
existing = listOf(dialect("Sesotho", "st", "Lesotho")),
catalogue = catalogue,
)
)
}
/** Typed by hand, with the whitespace and the capitals a person leaves behind. */
@Test
fun aHeldDialectStillCountsThroughWhitespaceAndCase() {
assertEquals(
listOf(swahiliKenya, swahiliTanzania),
DialectScaffold.missingFrom(
existing = listOf(dialect("sesotho", " ST ", " lesotho ")),
catalogue = catalogue,
)
)
}
/**
* The assertion that pins the identity rule. A room that typed the name
* differently still holds the dialect, and proposing it again would be the
* duplicate this whole function exists to avoid.
*/
@Test
fun theNameIsNotPartOfTheIdentity() {
assertEquals(
listOf(swahiliKenya, swahiliTanzania),
DialectScaffold.missingFrom(
existing = listOf(dialect("Southern Sotho", "st", "Lesotho")),
catalogue = catalogue,
)
)
}
/** A dialect is a language in a place, so one country does not answer for another. */
@Test
fun theSameLanguageInAnotherCountryIsStillMissing() {
assertEquals(
listOf(sesotho, swahiliTanzania),
DialectScaffold.missingFrom(
existing = listOf(dialect("Kiswahili", "sw", "Kenya")),
catalogue = catalogue,
)
)
}
@Test
fun aDialectTheCatalogueDoesNotListChangesNothing() {
assertEquals(
catalogue,
DialectScaffold.missingFrom(
existing = listOf(dialect("Nheengatu", "yrl", "Brazil")),
catalogue = catalogue,
)
)
}
@Test
fun whatIsPendingIsNotMissing() {
assertEquals(
listOf(swahiliTanzania),
DialectScaffold.missingFrom(
existing = listOf(dialect("Sesotho", "st", "Lesotho")),
pending = listOf(swahiliKenya),
catalogue = catalogue,
)
)
}
@Test
fun aRoomHoldingEverythingIsMissingNothing() {
assertEquals(
emptyList(),
DialectScaffold.missingFrom(
existing = catalogue.map { dialect(it.name, it.code, it.country) },
catalogue = catalogue,
)
)
}
@Test
fun dialectsOfEmitsOneDialectEventPerEntryInOrder() {
val templates = DialectScaffold.dialectsOf(catalogue, createdAt = 1_700_000_000L)
assertEquals(catalogue.size, templates.size)
assertTrue(templates.all { it.kind == DialectEvent.KIND })
assertTrue(
templates.all { it.createdAt == 1_700_000_000L },
"a wave proposed as one act should not read as entries that share a minute"
)
}
/**
* The round trip the feature actually depends on: what the picker chose is
* what the row will say once the group has signed it.
*/
@Test
fun eachTemplateRoundTripsBackToItsCatalogueEntry() {
val templates = DialectScaffold.dialectsOf(catalogue, createdAt = 1_700_000_000L)
val rows = templates.mapNotNull { template ->
MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = "k".repeat(64),
)
}
assertEquals(catalogue.size, rows.size, "a template did not parse back into a row")
assertEquals(
catalogue,
rows.map { AfricanLanguage(name = it.name, code = it.language, country = it.country) }
)
}
/**
* Two entries of one wave are two different events. Obvious, and worth an
* assertion: they share a `createdAt` and an author by construction, so the
* only thing separating their ids is their tags.
*/
@Test
fun entriesOfOneWaveGetDistinctIds() {
val rows = DialectScaffold.dialectsOf(catalogue, createdAt = 1_700_000_000L)
.mapNotNull { template ->
MantraDialect.fromDialectEventTemplate(
dialectEventTemplate = template,
chatRoomId = chatRoomId,
userPublicKey = "k".repeat(64),
)
}
assertEquals(rows.size, rows.map { it.id }.distinct().size)
}
}