diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt new file mode 100644 index 00000000..37efcea3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/DialectScaffold.kt @@ -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, + pending: List = emptyList(), + catalogue: List = AfricanLanguages.ALL, + ): List { + 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, + createdAt: Long, + ): List> = languages.map { language -> + DialectEvent.build( + name = language.name, + country = language.country, + language = language.code, + createdAt = createdAt, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt new file mode 100644 index 00000000..caa1e1f3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/DialectScaffoldTest.kt @@ -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) + } +}