From 395bfa6b8b1c8b4516a540b975e01c2d70ca0c4f Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:18:03 +0200 Subject: [PATCH 1/2] feat(subgroups): say which group the parent is, not only which id The parent card on a room's detail screen read "Parent group" over sixteen hex characters. Every other card naming a room names it -- `SubgroupCard` has shown `room?.chatRoom?.subject` since subgroups landed -- and the one card pointing the other way up the lineage did not, although the room it points at is usually right there in the database. So: "Parent group" moves to the overline, the group's name becomes the headline, and the truncated id moves underneath, where it can still be read against what the lineage says. The label keeps its place rather than being replaced, because a card carrying a name and nothing else says nothing about which way the lineage runs, and the signing key's card -- same icon column, same shape -- sits directly above this one. **The name can only come from the room, and there is not always one.** A birth certificate names the child and never the certifier, so a member of the subgroup who is not also in the group above holds a verified lineage with nothing to call it by. That case keeps the card it already had: the id as the headline, no supporting line. It is not a corner -- it is the ordinary position of anyone welcomed into a subgroup rather than founding one -- so the room is a nullable field rather than something to wait for. `SubgroupManager.parentGroupsOf` is `parentsOf` with each id looked up, returning `Parent(chatRoomId, room)` the way `subgroupsOf` returns `Subgroup`. The verified set and its canonical order are untouched; a parent whose room is missing stays in the list. The id-only `parentsOf` stays as it is, because `offersTo` compares ids and should not pay for rooms it will not read. Three tests: a parent this device is in is named by its room, one it is not in is still listed with nothing to name it, and two parents keep the lineage's own order -- asserted against `parentsOf` rather than a written-out order, since canonical means sorted by id and nobody should have to know how two derived ids sort. 459 common tests, 985 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseChatRepository.kt | 4 +- .../compose/managers/SubgroupManager.kt | 29 +++++++++ .../compose/repository/ChatRepository.kt | 10 +++- .../ui/composable/ChatRoomDetailScreen.kt | 59 ++++++++++++++----- .../ui/view/model/ChatRoomDetailViewModel.kt | 2 +- .../ui/view/state/ChatRoomDetailUIState.kt | 7 ++- .../managers/SubgroupManagerJvmTest.kt | 48 +++++++++++++++ 7 files changed, 138 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 3463a4e5..70e05e89 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -192,8 +192,8 @@ class DatabaseChatRepository( false } - override suspend fun parentsOf(chatRoomId: String): List = try { - SubgroupManager.parentsOf(database, chatRoomId) + override suspend fun parentsOf(chatRoomId: String): List = try { + SubgroupManager.parentGroupsOf(database, chatRoomId) } catch (e: Throwable) { logger.e("Error reading the parents of $chatRoomId", e) emptyList() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index a10c237c..7ca84bcf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -842,6 +842,35 @@ object SubgroupManager { database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() .orEmpty() + /** + * A group this room is a child of, plus the room itself where this device + * has it. + * + * [room] is the only place a parent's *name* can come from: a certificate + * names the child, never the certifier, so a device that is not in the + * parent room knows the lineage and not what to call it. Null is therefore + * the ordinary case for a subgroup member who is not also a parent member, + * and the id is all such a screen can honestly show. + */ + data class Parent( + val chatRoomId: HexKey, + val room: LocalChatRoom?, + ) + + /** + * [parentsOf], with each parent's room where this device holds one. + * + * The same verified set in the same order -- this only looks each id up, and + * a parent whose room is missing stays in the list rather than being dropped. + */ + suspend fun parentGroupsOf(database: MantraDatabase, chatRoomId: String): List = + parentsOf(database, chatRoomId).map { parentChatRoomId -> + Parent( + chatRoomId = parentChatRoomId, + room = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ) + } + /** * What a certificate arriving in a room is, or null if it is nothing this * room should believe. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 319e3782..222cdea5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -78,8 +78,12 @@ interface ChatRepository { * may be adopted by another later. A member welcomed in after the founding * holds no state and gets an empty list even when the room really is a * subgroup. + * + * Each carries the parent's room where this device has one, which is the + * only place its name can come from -- a certificate names the child and + * never the certifier. */ - suspend fun parentsOf(chatRoomId: String): List + suspend fun parentsOf(chatRoomId: String): List /** * One event the group signed, as the group signed it, or null when this @@ -323,7 +327,9 @@ interface ChatRepository { override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() - override suspend fun parentsOf(chatRoomId: String): List = emptyList() + override suspend fun parentsOf( + chatRoomId: String + ): List = emptyList() override suspend fun groupSignedEvent( chatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 9125dfbb..f3bcf1ea 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -280,15 +280,40 @@ fun ChatRoomDetailScreen( // later. They are a set rather than a path -- neither // is "the" parent and the order says nothing about // which came first -- so each gets an equal row. - chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId -> - item(key = "parent-$parentChatRoomId") { + chatRoomDetailUIState.parents.forEach { parent -> + item(key = "parent-${parent.chatRoomId}") { + // The parent's own name, which only its room + // can give: a certificate names the child and + // never the certifier, so a member of the + // subgroup who is not also in the parent has + // the id and nothing else to call it by. + val name = parent.room + ?.chatRoom + ?.subject + ?.takeIf { it.isNotBlank() } + + // Shown either way -- as the headline where + // there is no name, and under it where there + // is, since it is the part that can be + // compared against what the lineage says. + val identifier: @Composable () -> Unit = { + Text( + text = parent.chatRoomId + .take(16) + .inComparableGroups(), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + Card( modifier = Modifier.fillMaxWidth(), onClick = { onNavigateToRoute.invoke( ChatRoomDetailRoute( activeUserPublicKey = activeUserPublicKey, - chatRoomId = parentChatRoomId, + chatRoomId = parent.chatRoomId, relayHint = relayHint ) ) @@ -307,19 +332,25 @@ fun ChatRoomDetailScreen( contentDescription = Decorative ) }, - headlineContent = { + // The label keeps its place above the + // name rather than being replaced by + // it: a card carrying a group's name + // and nothing else says nothing about + // which way the lineage runs, and the + // signing key's card -- same icon + // column, same shape -- sits directly + // above this one. + overlineContent = { Text(text = stringResource(Res.string.parent_group)) }, - supportingContent = { - Text( - text = parentChatRoomId - .take(16) - .inComparableGroups(), - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis - ) - } + headlineContent = { + if (name != null) { + Text(text = name) + } else { + identifier() + } + }, + supportingContent = identifier.takeIf { name != null } ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 6b7c16f3..8b79af88 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -82,7 +82,7 @@ class ChatRoomDetailViewModel( groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), - parentChatRoomIds = chatRepository.parentsOf(chatRoomId), + parents = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index d357797e..9b976403 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -41,15 +41,18 @@ sealed interface ChatRoomDetailUIState { */ val subgroups: List = emptyList(), /** - * The group this room is a subgroup of, or null when it is nobody's + * The groups this room is a subgroup of, empty when it is nobody's * child. * * Verified: it comes off the room's key state, which only carries a * parent whose certificate was checked. A member welcomed in after the * founding holds no state and so sees nothing here, even in a room that * really is a subgroup. + * + * Each carries the parent's room where this device holds one, so the + * card can name the group rather than only identify it. */ - val parentChatRoomIds: List = emptyList(), + val parents: List = emptyList(), /** * Whether this device could open a subgroup here at all. * diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index 32ee1bd3..ca78097c 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -777,6 +777,54 @@ class SubgroupManagerJvmTest { Unit } + @Test + fun `a parent this device is in is named by its room`() = runBlocking { + parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId)) + + val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single() + + assertEquals(parentRoomId, parent.chatRoomId) + // The room, and nothing else, is where a parent's name comes from: a + // certificate names the child and never the certifier. + assertEquals("Ekklesia", parent.room?.chatRoom?.subject) + } + + @Test + fun `a parent this device is not in is still listed, with nothing to name it`() = runBlocking { + // No parent room: the ordinary position of a subgroup member who is not + // also in the group above. The lineage is known -- it was checked + // against that group's own signature -- so the parent stays in the list + // and only its name is missing. + childWithKeyState(parents = listOf(parentRoomId)) + + val parent = SubgroupManager.parentGroupsOf(db, childRoomId).single() + + assertEquals(parentRoomId, parent.chatRoomId) + assertNull(parent.room) + } + + @Test + fun `two parents keep the lineage's own order, named or not`() = runBlocking { + parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId, siblingRoomId)) + + val parents = SubgroupManager.parentGroupsOf(db, childRoomId) + + // The same set in the same order -- canonical, which is sorted by id and + // so says nothing about which parent came first. Asserted against + // `parentsOf` rather than against a written-out order, since the ids are + // derived from keys and nobody should have to know how two of them sort. + assertEquals( + SubgroupManager.parentsOf(db, childRoomId), + parents.map { it.chatRoomId } + ) + assertEquals( + mapOf(parentRoomId to "Ekklesia", siblingRoomId to null), + parents.associate { it.chatRoomId to it.room?.chatRoom?.subject } + ) + } + private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? = SubgroupManager.refuseAdoption( database = db, From aff1e330a0bdc2947a3916aa70b053d95a6cec7c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 22:21:49 +0200 Subject: [PATCH 2/2] fix(repository): a no-op answers all sixty calls, and a test keeps it total Sixty `TODO("Not yet implemented")` bodies sat across four no-op repositories -- 41 of 45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on `MarmotRepository`, 1 of 1 on `SearchRepository`. f46a7c62 fixed the one that was firing and flagged the rest for their own change; this is that change. **Why a stub here is not one test's problem.** These are reached from view models that call their repository inside `viewModelScope.launch(Dispatchers.IO)`, a coroutine nobody awaits. The `NotImplementedError` escapes to the platform handler, where kotlinx-coroutines-test's `ExceptionCollector` stashes it in `unprocessedExceptions` -- a `runBlocking` test has no `TestScope` registered to take it -- and the *next* `runTest` drains the stash and rethrows it as `UncaughtExceptionsBeforeTest`, under its own name, for a call it never made. Reproduced before touching anything: `jvmTest --rerun-tasks` failed `NavigationRoutingTest > an account already on the device does not stay on the loading screen` with four suppressed `NotImplementedError`s, each naming `NostrRepository.kt:376` from `ChatMessageListViewModel.kt:366`. Seventeen jvmTest files build on the Nostr one alone. **Inert, not loud -- for every member.** The question per member was whether a silent body or a deliberate throw is right, and the callers settle it: the only two populations are `@ConformancePreviews` composables in commonMain and test fixtures, and both want a repository that has no data rather than one that refuses. A preview that throws is a broken preview. `NO_OP_DKG_REPOSITORY` and `NO_OP_FROST_SIGNING_REPOSITORY` already work this way, so this follows that idiom instead of inventing a second one. **Throwing is never the mechanism for being loud.** Keeping a throw for members that "should not be called" was the alternative, and it is wrong twice: it re-opens exactly this bug the next time such a member is reached from an unawaited launch, and it puts the assertion in the fixture instead of the test. A member a test must not call is that test's assertion to make -- override it with a recorder and assert it stayed untouched. **A callback is not invoked.** `onCompletion`, `onSuccess` and `onError` are left alone by `createNewProfile`, `createNewTextNote`, `leaveChatRoom`, `softDeleteChatRoom` and `deleteChatRoom`. Calling `onCompletion` would say work happened that did not, which is how a test comes to pass on the fixture rather than on the code; a test that wants a branch fired overrides the member and fires the one it is testing. **A flow emits its empty value once, rather than never.** `flowOf(null)` and `flowOf(emptyList())` throughout, and `ChatRepository`'s two existing `flow { }` stubs converted with them. An empty flow leaves a screen in Loading for ever; one emission lets it render its empty state, which is what a preview is for. **`createMlsDirectMessage` is the one that cannot say "nothing".** It owes the id of a room and `String` has no absent value, so it returns `""`, documented as such. The signature is the real oddity -- both creators beside it can report failure (`createNip17ChatRoom` returns null, `createMarmotGroup` an `Outcome`) -- but changing it would touch `DatabaseChatRepository` and its caller, so it is flagged in a comment rather than widened here. Nothing reaches it in practice: `initiateNewChat` calls it only once a profile and a key package have both resolved, and against this object neither ever does. **Nothing was passing vacuously, checked rather than assumed.** The risk in a blanket sweep is a test that currently fails honestly starting to pass on an empty body. No test anywhere asserts `NotImplementedError`, and the suite went from 982 tests with 1 failure to 983 with 0, with nothing newly failing -- including across the `flow { }` change and the callback decision. **The guard discovers the singletons rather than listing them.** `NoOpRepositoryTotality` reads the `repository/` directory, keeps every file declaring a `NO_OP_` val, and reports each `TODO(` in code with its file, line and member -- comments excluded, since the contract on `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its own prose. A hand-written list was the first attempt and was already wrong: it named six files when the tree holds eight, missing `MantraRepository` and `CachingImportRepository`, both total by luck rather than cover. A floor of 8 goes with it, in the spirit of `m3-audit.sh`'s floors, because the way a source-reading test fails silently is by finding nothing and passing. Both halves were mutation-tested: a reintroduced `TODO()` fails naming `MarmotRepository.kt:49` and its member, and a floor of 99 reports all eight files found. **Calling the members for real was the alternative, and tells you less.** Sixty live calls need a fixture for every parameter type, and a reflective sweep cannot tell a body that threw from an argument it could not build -- so a failure would not distinguish the bug from the harness. The invariant is a source-level one, so it is checked there. **f46a7c62's narrow override is removed.** With the no-op total, the `object : NostrRepository by ...` in `CeremonyQueueRoutingJvmTest` is dead weight, and its comment had become false -- it asserted the member is "one of the forty-one `TODO()` bodies". Replaced with the bare no-op and a past-tense note pointing at the guard. **Not done here: the reporting mechanism.** The open question was whether the suite should fail fast on an uncaught coroutine exception, or name the right test. Tracing kotlinx-coroutines-test 1.11 shows the culprit's stack trace is already preserved, as a suppressed cause on the `UncaughtExceptionsBeforeTest` -- nothing is lost, it is only filed under the wrong name, and Gradle's console shows just the top line. A handler that re-printed it would add noise, not information, so none was added; the missing name is what this commit supplies. `MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE` is also still misnamed -- it is a repository, not a bundle -- and left for its own change. 1171 jvm tests, 0 failed, under `--rerun-tasks`, on the merged tree. m3Audit clean, `:composeApp:compileDebugKotlinAndroid` green. Co-Authored-By: Claude Opus 5 --- .../compose/repository/ChatRepository.kt | 87 ++++--- .../compose/repository/MarmotRepository.kt | 42 ++-- .../compose/repository/NostrRepository.kt | 234 ++++++++---------- .../compose/repository/SearchRepository.kt | 15 +- .../NoOpRepositoryTotalityJvmTest.kt | 138 +++++++++++ .../view/model/CeremonyQueueRoutingJvmTest.kt | 26 +- 6 files changed, 319 insertions(+), 223 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 319e3782..6336bc24 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -19,7 +19,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf interface ChatRepository { suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> @@ -308,7 +308,13 @@ interface ChatRepository { ): MarmotReindexReport companion object { - val NO_OP_CHAT_REPOSITORY = object: ChatRepository { + /** + * A chat repository with no database behind it: every room is absent, every + * membership change is refused with a reason, and no member throws. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure, and for the rules a new member follows. + */ + val NO_OP_CHAT_REPOSITORY: ChatRepository = object : ChatRepository { override suspend fun requestGroupHistoryIfMissing( chatRoomId: String, userPublicKey: HexKey @@ -338,9 +344,8 @@ interface ChatRepository { coordinatorPublicKey: HexKey, ): String? = "No database" - override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { - return flow { } - } + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> = + flowOf(emptyList()) override suspend fun getChatRoomListByPublicKey(publicKey: String): List { return emptyList() @@ -375,22 +380,18 @@ interface ChatRepository { userPublicKey: HexKey ): Boolean = false - override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { - TODO("Not yet implemented") - } + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List = + emptyList() - override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? { - TODO("Not yet implemented") - } + override suspend fun findDirectMessageRoom(userPublicKey: HexKey, peerPublicKey: HexKey): LocalChatRoom? = + null override suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List { return emptyList() } - override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> { - return flow { - } - } + override suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow> = + flowOf(emptyList()) override suspend fun getOrCreateChatRoom( chatRoomId: String, @@ -407,13 +408,11 @@ interface ChatRepository { return null } - override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? { - TODO("Not yet implemented") - } + override suspend fun getMarmotKeyPackageForPublicKey(publicKey: HexKey): MarmotKeyPackage? = + null - override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow = + flowOf(null) override suspend fun createNip17ChatRoom( userPublicKey: HexKey, @@ -424,23 +423,30 @@ interface ChatRepository { return null } + /** + * The empty id, meaning no room was created. + * + * The only member here whose return type cannot say "nothing": it owes the id + * of a room, and `String` has no absent value. Both creators beside it can say + * they failed -- [createNip17ChatRoom] returns null, [createMarmotGroup] an + * [MarmotGroupCreation.Outcome] -- so this one's signature is the odd one out + * rather than this body being the odd answer. Nothing reaches it in practice: + * `ChatRoomMessagingViewModel.initiateNewChat` calls it only once a profile and + * a key package have both resolved, and against this object neither ever does. + */ override suspend fun createMlsDirectMessage( name: String?, description: String?, userPublicKey: HexKey, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage - ): String { - TODO("Not yet implemented") - } + ): String = "" override suspend fun inviteMember( localChatRoom: LocalChatRoom, peerPublicKey: HexKey, peerKeyPackage: MarmotKeyPackage - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun addMembers( localChatRoom: LocalChatRoom, @@ -464,45 +470,36 @@ interface ChatRepository { localChatRoom: LocalChatRoom, messageType: Kind, directMessageRecipientPublicKey: HexKey? - ) { - // TODO("Not yet implemented") - } + ) = Unit override fun updateChatRoomSubject(publicKey: String, subject: String): ChatRoom? { return null } - override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnsealedGiftWrapPayloads(publicKey: HexKey): Flow> = + flowOf(emptyList()) override suspend fun sealGiftWrapPayload( giftWrapPayload: GiftWrapPayload, nostrSignerSync: NostrSignerSync - ) { - TODO("Not yet implemented") - } + ) = Unit + // None of the three calls onCompletion: nothing was left or deleted, and each + // of these callbacks navigates away from a room that is still there. override suspend fun leaveChatRoom( localChatRoom: LocalChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun softDeleteChatRoom( chatRoom: ChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun deleteChatRoom( chatRoom: ChatRoom, onCompletion: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun reindexMarmotGroupEvents( chatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt index c9875665..0f9af0d5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/MarmotRepository.kt @@ -5,6 +5,7 @@ import press.mantra.compose.database.model.MarmotKeyPackageBundle import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface MarmotRepository { @@ -33,40 +34,35 @@ interface MarmotRepository { suspend fun encryptAndSendMarmotInnerEvent(marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync) companion object { - val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE = object: MarmotRepository { + /** + * A repository with no key packages behind it: inert, and no member throws. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure, and for the rules a new member follows. + */ + val NO_OP_MARMOT_KEY_PACKAGE_BUNDLE: MarmotRepository = object : MarmotRepository { override suspend fun publishMarmotKeyPackageBundle( publicKey: HexKey, nsecPassword: String - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) { - TODO("Not yet implemented") - } + override suspend fun rotateMarmotKeyPackageBundle(marmotKeyPackageBundle: MarmotKeyPackageBundle) = Unit - override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeMarmotKeyPackageBundles(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List { - return emptyList() - } + override suspend fun getMarmotKeyPackageBundles(publicKey: HexKey): List = + emptyList() - override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnprocessedMarmotInnerEvents(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeActiveMarmotKeyPackageBundle(publicKey: HexKey): Flow = + flowOf(null) override suspend fun encryptAndSendMarmotInnerEvent( marmotInnerEvent: MarmotInnerEvent, nostrSignerSync: NostrSignerSync - ) { - TODO("Not yet implemented") - } + ) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 4e8a9805..0a8d6432 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -20,6 +20,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf interface NostrRepository { suspend fun getLocalAccounts(): List @@ -209,78 +210,97 @@ interface NostrRepository { suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) companion object { - val NO_OP_NOSTR_REPOSITORY = object : NostrRepository { - override suspend fun getLocalAccounts(): List { - TODO("Not yet implemented") - } + /** + * A repository with nothing behind it: every question answers "nothing here", + * every write goes nowhere, and **no member throws**. + * + * Previews and test fixtures are the only callers, and both want inertness. It is + * written out here because these members were once `TODO()`, and a `TODO()` in a + * no-op is not a loud failure -- it is a silent misattribution. Callers reach most + * of them from a `viewModelScope.launch(Dispatchers.IO)` that nobody awaits, so the + * `NotImplementedError` escapes to the platform handler, is stashed by + * kotlinx-coroutines-test's `ExceptionCollector`, and is re-thrown as + * `UncaughtExceptionsBeforeTest` inside *the next* `runTest` -- under that test's + * name, with the real culprit buried as a suppressed cause. One `TODO()` here + * (`queueNegentropySynchronizeRequest`, reached from + * `ChatMessageListViewModel.scheduleSynchronization`) failed `NavigationRoutingTest` + * four times a run for a call that test never made. + * + * So the rules for a member added here, which the other three no-op repositories + * follow too: + * + * - **Answer the call, never stub it out.** `NoOpRepositoryTotalityJvmTest` reads + * these four objects and fails if an unimplemented stub reappears in one. + * - **A flow emits its empty value once** -- `flowOf(null)`, `flowOf(emptyList())` -- + * rather than `flow { }`. A screen collecting an empty flow waits in Loading + * forever; one collecting `flowOf(null)` renders its empty state, which is what + * a preview is for. + * - **A callback is not invoked.** Nothing happened, and calling `onCompletion` or + * `onSuccess` would say it did -- letting a test pass on the fixture rather than + * on the code under test. + * - **Throwing is never the way to be loud.** A member that a test must not call is + * the test's assertion to make, not the fixture's: override it with a recorder and + * assert it stayed untouched. An exception thrown here lands on whichever test + * runs next. + * + * A test that needs a real answer from one member overrides just that one: + * `object : NostrRepository by NO_OP_NOSTR_REPOSITORY { … }`. + */ + val NO_OP_NOSTR_REPOSITORY: NostrRepository = object : NostrRepository { + override suspend fun getLocalAccounts(): List = emptyList() - override suspend fun observeLocalAccount(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeLocalAccount(publicKey: HexKey): Flow = + flowOf(null) - override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? { - TODO("Not yet implemented") - } + override suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? = null - override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithPublicKey(publicKey: HexKey): Flow = + flowOf(null) override suspend fun observeRelation( publicKey: HexKey, activePublicKey: HexKey - ): Flow> { - TODO("Not yet implemented") - } + ): Flow> = flowOf(emptyList()) - override suspend fun deleteConnection(connection: Connection) { - TODO("Not yet implemented") - } + override suspend fun deleteConnection(connection: Connection) = Unit - override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeUnsignedNostrEvents(publicKey: HexKey): Flow> = + flowOf(emptyList()) - override suspend fun observePendingBroadcastNostrEventRequests(): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingBroadcastNostrEventRequests(): Flow = + flowOf(null) override suspend fun reopenInbox(activeKeyPair: KeyPair): Int = 0 - override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingSynchronizeNostrEventRequests(ownerPublicKey: HexKey): Flow = + flowOf(null) - override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow { - TODO("Not yet implemented") - } + override suspend fun observePendingNegentropySynchronizeRequests(ownerPublicKey: HexKey): Flow = + flowOf(null) + // onCompletion is deliberately not called: no profile was created, and a + // fixture that reports completion is a fixture a test can pass against. override suspend fun createNewProfile( publicKey: HexKey, name: String?, biography: String?, onCompletion: suspend () -> Unit - ) { - TODO("") - } + ) = Unit - override suspend fun signInToProfile(publicKey: HexKey) { - TODO("Not yet implemented") - } + override suspend fun signInToProfile(publicKey: HexKey) = Unit override suspend fun setUpProfileForExistingKey( publicKey: HexKey, placeholderUnsignedNostrEventId: Long, name: String?, biography: String?, - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun forgetLocalAccount(publicKey: HexKey) { - TODO("Not yet implemented") - } + override suspend fun forgetLocalAccount(publicKey: HexKey) = Unit + // Neither onSuccess nor onError: the note was not written, and it did not fail + // to be written either -- there was nowhere to write it. A test that wants + // either branch overrides this member and calls the one it is testing. override suspend fun createNewTextNote( publicKey: HexKey, mentionedPublicKeys: List, @@ -289,74 +309,50 @@ interface NostrRepository { quotedNostrEvent: LocalNostrEvent?, onError: () -> Unit, onSuccess: () -> Unit - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? { - return null - } + override suspend fun saveUnsignedNostrEvent(unsignedNostrEvent: UnsignedNostrEvent): UnsignedNostrEvent? = + null - override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List) { - TODO("Not yet implemented") - } + override suspend fun saveUnsignedNostrEvents(unsignedNostrEvents: List) = Unit - override suspend fun wipeDatabase() { - } + override suspend fun wipeDatabase() = Unit override suspend fun publishNostrEvent( unsignedNostrEvent: UnsignedNostrEvent, nostrEvent: NostrEvent, relayURLs: List, activeKeyPair: KeyPair - ) { - - } + ) = Unit override suspend fun broadcastProcessed( broadcastNostrEventRequest: BroadcastNostrEventRequest, status: String - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun requeueStaleBroadcastNostrEventRequests(): Int { - TODO("Not yet implemented") - } + override suspend fun requeueStaleBroadcastNostrEventRequests(): Int = 0 - override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { - TODO("Not yet implemented") - } + override suspend fun synchronizeNostrEventRequestProcessed(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit - override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) { - TODO("Not yet implemented") - } + override suspend fun synchronizeNostrEventRequestCompleted(synchronizeNostrEventRequest: SynchronizeNostrEventRequest) = Unit - override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) { - TODO("Not yet implemented") - } + override suspend fun negentropySynchronizeRequestProcessed(negentropySynchronizeRequest: NegentropySynchronizeRequest) = Unit - override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) { - TODO("Not yet implemented") - } + override suspend fun saveBroadcastReceipt(broadcastNostrEventReceipt: BroadcastNostrEventReceipt) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, synchronizeNostrEventRequest: SynchronizeNostrEventRequest, synchronizationRelayURLs: List, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, negentropySynchronizeRequest: NegentropySynchronizeRequest, synchronizationRelayURLs: List, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit override suspend fun saveNostrEvent( nostrEvent: NostrEvent, @@ -364,82 +360,54 @@ interface NostrRepository { synchronizationRelayURLs: List, level: Int, activeKeyPair: KeyPair - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { - TODO("Not yet implemented") - } + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) = Unit - override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) { - TODO("Not yet implemented") - } + override suspend fun queueNegentropySynchronizeRequest(negentropySynchronizeRequests: List) = Unit - override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeSynchronizeNostrEventRequestByPurposeAndStatusCount(purpose: String): Flow = + flowOf(0) override suspend fun getNostrEvent( publicKey: HexKey, kind: Kind - ): NostrEvent? { - TODO("Not yet implemented") - } + ): NostrEvent? = null - override suspend fun observeNostrFeed(): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeNostrFeed(): Flow> = flowOf(emptyList()) - override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> { - TODO("Not yet implemented") - } + override suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> = + flowOf(emptyList()) override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean - ): List { - TODO("Not yet implemented") - } + ): List = emptyList() - override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeLocalNostrEventById(nostrEventId: String): Flow = + flowOf(null) - override suspend fun searchableProfiles(excludingPublicKeys: Array): List { - TODO("Not yet implemented") - } + override suspend fun searchableProfiles(excludingPublicKeys: Array): List = + emptyList() - override suspend fun filterProfiles(searchTerm: String): List { - TODO("Not yet implemented") - } + override suspend fun filterProfiles(searchTerm: String): List = emptyList() - override suspend fun observeProfileWithFollowers(publicKey: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithFollowers(publicKey: String): Flow = + flowOf(null) - override suspend fun observeProfileWithFollowing(publicKey: String): Flow { - TODO("Not yet implemented") - } + override suspend fun observeProfileWithFollowing(publicKey: String): Flow = + flowOf(null) - override suspend fun getRecentSearches(): List { - TODO("Not yet implemented") - } + override suspend fun getRecentSearches(): List = emptyList() - override suspend fun removeRecentSearch(recentSearch: RecentSearch) { - TODO("Not yet implemented") - } + override suspend fun removeRecentSearch(recentSearch: RecentSearch) = Unit override suspend fun saveSearchQuery( query: String, synchronizationFilterArray: press.mantra.compose.database.model.typealiases.SynchronizationFilterArray? - ) { - TODO("Not yet implemented") - } + ) = Unit - override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) { - TODO("Not yet implemented") - } + override suspend fun rescheduleBroadcastNostrEventRequests(broadcastNostrEventRequests: List) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt index 71aeb2b3..4e07e9d3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/SearchRepository.kt @@ -7,10 +7,15 @@ interface SearchRepository { ) companion object { - val NO_OP_SEARCH_REPOSITORY = object: SearchRepository { - override suspend fun submitSearch(synchronizeNostrEventRequests: List) { - TODO("Not yet implemented") - } + /** + * A search that reaches no relay: inert, and the one member does not throw. See + * [NostrRepository.NO_OP_NOSTR_REPOSITORY] for why a `TODO()` here is a silent + * misattribution rather than a loud failure. + */ + val NO_OP_SEARCH_REPOSITORY: SearchRepository = object : SearchRepository { + override suspend fun submitSearch( + synchronizeNostrEventRequests: List + ) = Unit } } -} \ No newline at end of file +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt new file mode 100644 index 00000000..d719af03 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/repository/NoOpRepositoryTotalityJvmTest.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.repository + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Every member of every `NO_OP_*` repository answers its call. None is left stubbed out. + * + * Every one in `commonMain/.../repository/`, found by reading the directory rather than by + * naming files -- see [declaration]. + * + * The rule is worth a test of its own because breaking it does not fail where it is broken. + * A `TODO()` in one of these objects throws `NotImplementedError`, and the callers are view + * models that reach their repository from `viewModelScope.launch(Dispatchers.IO)` -- a + * coroutine nobody awaits. The error escapes to the platform handler, where + * kotlinx-coroutines-test's `ExceptionCollector` stashes it (`unprocessedExceptions`, because + * a `runBlocking` test has no `TestScope` registered to take it), and the *next* `runTest` to + * start drains the stash and rethrows it as `UncaughtExceptionsBeforeTest` -- under that + * test's name, for a call it never made. The real stack trace survives only as a suppressed + * cause several frames down. + * + * That cost a debugging session once: `queueNegentropySynchronizeRequest` was a `TODO()`, + * `CeremonyQueueRoutingJvmTest` reached it through `ChatMessageListViewModel.initiate()`, and + * `NavigationRoutingTest > an account already on the device does not stay on the loading + * screen` failed four times a run while the test that caused it passed. Seventeen test files + * build on `NO_OP_NOSTR_REPOSITORY` alone, so one stub is never one test's problem. + * + * So this is checked at the only altitude where it can be checked cheaply and totally: the + * source. Calling all sixty-odd members for real would mean a fixture for every parameter + * type, and a test that fails because a fixture was wrong tells you less than this one does. + * A reflective sweep has the same problem -- it cannot tell a body that threw from an argument + * it could not build. + * + * It fails under its own name, which is the whole point. + */ +class NoOpRepositoryTotalityJvmTest { + + /** + * Every file that declares one, found rather than listed. + * + * A list would be wrong the first time a ninth repository is added -- and was: the + * first version of this test named six files when the tree held eight, having missed + * `MantraRepository` and `CachingImportRepository` (both total, which is luck rather + * than cover). Discovery is the point of the test, so it discovers. + */ + private val declaration = Regex("""\bval\s+NO_OP_[A-Z0-9_]+""") + + /** + * A floor, in the spirit of `docs/scripts/m3-audit.sh`: the way this test breaks + * silently is by finding nothing to check -- a moved package, a renamed convention -- + * and then passing. Eight is the count on 2026-10-02. Raise it when one is added. + */ + private val knownSingletons = 8 + + @Test + fun `no no-op repository leaves a member unimplemented`() { + val directory = repositoryDirectory() + + val sources = (directory.listFiles() ?: emptyArray()) + .filter { it.isFile && it.name.endsWith(".kt") } + .map { it.name to it.readText() } + .filter { (_, source) -> declaration.containsMatchIn(source) } + .sortedBy { (name, _) -> name } + + assertTrue( + sources.size >= knownSingletons, + "Found ${sources.size} no-op repositories in ${directory.path}, expected at " + + "least $knownSingletons. Either one was removed -- lower the floor in the " + + "same change -- or this test has stopped finding them and is passing on " + + "nothing. Found: ${sources.map { it.first }}" + ) + + val offences = sources.flatMap { (name, source) -> stubbedMembers(name, source) } + + assertEquals( + emptyList(), + offences, + "A no-op repository member is stubbed out. Give it the answer a repository with " + + "nothing behind it would give -- null, an empty collection, flowOf(...) of " + + "one of those, Unit, 0, false -- and never TODO(). See the contract on " + + "NostrRepository.NO_OP_NOSTR_REPOSITORY. Stubs found:\n" + + offences.joinToString("\n") { " $it" } + ) + } + + /** + * `file:line member` for every `TODO(` in code, comments excluded -- the contract on + * `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its prose, and a doc comment explaining the + * rule must not trip the check that enforces it. + */ + private fun stubbedMembers(name: String, source: String): List { + val found = mutableListOf() + var inBlockComment = false + var member = "(before the first declaration)" + + source.lines().forEachIndexed { index, raw -> + var line = raw + if (inBlockComment) { + val end = line.indexOf("*/") + if (end < 0) return@forEachIndexed + line = line.substring(end + 2) + inBlockComment = false + } + val blockStart = line.indexOf("/*") + if (blockStart >= 0) { + inBlockComment = !line.substring(blockStart).contains("*/") + line = line.substring(0, blockStart) + } + val lineComment = line.indexOf("//") + if (lineComment >= 0) line = line.substring(0, lineComment) + + if (line.contains("fun ")) member = line.trim() + if (line.contains("TODO(")) found += "$name:${index + 1} $member" + } + return found + } + + /** + * Found by walking up from the working directory rather than taken from it, because the + * one is the `composeApp` project directory under `:composeApp:jvmTest` and the other is + * the repository root when the same test is run from the root project. A test about + * things that fail silently does not get to pass because it could not find its input. + */ + private fun repositoryDirectory(): File { + val relative = "composeApp/src/commonMain/kotlin/press/mantra/compose/repository" + var directory: File? = File("").absoluteFile + while (directory != null) { + val candidate = File(directory, relative) + if (candidate.isDirectory) return candidate + directory = directory.parentFile + } + throw AssertionError( + "Could not find $relative above ${File("").absolutePath} -- this test read nothing." + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt index faaec773..5093b1be 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.delay import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession -import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalParticipant @@ -152,23 +151,16 @@ class CeremonyQueueRoutingJvmTest { val viewModel = ChatMessageListViewModel( initialChatMessageListUIState = ChatMessageListUIState.Loading, localChatRoom = room(), - // Not the bare no-op. `initiate` calls `scheduleSynchronization` on - // its second line, which launches on `Dispatchers.IO` and -- because + // The bare no-op is safe again. `initiate` calls `scheduleSynchronization` + // on its second line, which launches on `Dispatchers.IO` and -- because // `room()` gives every participant a null profile, so there is always - // somebody it cannot name -- reaches - // `queueNegentropySynchronizeRequest`. That member is one of the - // forty-one `TODO("Not yet implemented")` bodies on - // NO_OP_NOSTR_REPOSITORY, so it throws in a coroutine nobody awaits. - // - // Nothing catches it, so it reaches the default handler, and the next - // `runTest` in the same JVM reports it as `UncaughtExceptionsBeforeTest` - // -- against its own name. That is how this test failed - // `NavigationRoutingTest` four times a run while passing itself. - nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { - override suspend fun queueNegentropySynchronizeRequest( - negentropySynchronizeRequests: List - ) = Unit - }, + // somebody it cannot name -- reaches `queueNegentropySynchronizeRequest`. + // That member used to be one of forty-one `TODO()` bodies on + // NO_OP_NOSTR_REPOSITORY and threw into whichever `runTest` ran next, + // which is how this test failed `NavigationRoutingTest` four times a run + // while passing itself. The override that stood here is gone because the + // no-op is total now; `NoOpRepositoryTotalityJvmTest` keeps it that way. + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY {