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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user