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 {