app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit b55ca17b212db683209f89cd94eabc4260764d56
parent 54e67634396aabaa947b8f4cef7d97d726bef44a
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:01:59 +0000

cleanup: remove Kotlin account reducer proof

- delete the UUID account identity and local login state model
- remove the duplicate Kotlin reducer and mutable account store
- retire the generic account form and its obsolete test fixtures
- retain only Rust-snapshot presentation models and Compose coverage

Diffstat:
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/model/Account.kt | 13-------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountId.kt | 6------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsAction.kt | 37-------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsProblem.kt | 22----------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsState.kt | 74--------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountIdFactory.kt | 7-------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt | 151------------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsStore.kt | 21---------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsScreen.kt | 273-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/TestAccounts.kt | 26--------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/model/AccountsStateTest.kt | 122-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt | 349-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsStoreTest.kt | 75---------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsScreenTest.kt | 305------------------------------------------------------------------------------
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
15 files changed, 1 insertion(+), 1482 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/Account.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/Account.kt @@ -1,13 +0,0 @@ -package org.radroots.studio.accounts.model - -enum class LoginStatus { - LoggedOut, - LoggedIn, -} - -data class Account( - val id: AccountId, - val displayName: String, - val serverUrl: String, - val loginStatus: LoginStatus, -) diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountId.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountId.kt @@ -1,6 +0,0 @@ -package org.radroots.studio.accounts.model - -@JvmInline -value class AccountId( - val value: String, -) diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsAction.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsAction.kt @@ -1,37 +0,0 @@ -package org.radroots.studio.accounts.model - -sealed interface AccountsAction { - data class EditAddDisplayName( - val value: String, - ) : AccountsAction - - data class EditAddServerUrl( - val value: String, - ) : AccountsAction - - data object SubmitAddAccount : AccountsAction - - data class SelectAccount( - val accountId: AccountId, - ) : AccountsAction - - data class LogIn( - val accountId: AccountId, - ) : AccountsAction - - data class LogOut( - val accountId: AccountId, - ) : AccountsAction - - data class RequestRemoveAccount( - val accountId: AccountId, - ) : AccountsAction - - data object CancelRemoveAccount : AccountsAction - - data class ConfirmRemoveAccount( - val accountId: AccountId, - ) : AccountsAction - - data object DismissProblem : AccountsAction -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsProblem.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsProblem.kt @@ -1,22 +0,0 @@ -package org.radroots.studio.accounts.model - -sealed interface AccountsProblem { - data object BlankDisplayName : AccountsProblem - - data object InvalidServerUrl : AccountsProblem - - data object InvalidGeneratedAccountId : AccountsProblem - - data class DuplicateAccountId( - val accountId: AccountId, - ) : AccountsProblem - - data class AccountNotFound( - val accountId: AccountId, - ) : AccountsProblem - - data class RemovalTargetMismatch( - val expectedAccountId: AccountId?, - val actualAccountId: AccountId, - ) : AccountsProblem -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsState.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/model/AccountsState.kt @@ -1,74 +0,0 @@ -package org.radroots.studio.accounts.model - -import java.net.URI - -data class AddAccountDraft( - val displayName: String = "", - val serverUrl: String = "", -) - -data class AccountsState( - val accounts: List<Account> = emptyList(), - val selectedAccountId: AccountId? = null, - val addDraft: AddAccountDraft = AddAccountDraft(), - val pendingRemovalAccountId: AccountId? = null, - val problem: AccountsProblem? = null, -) - -internal fun normalizeDisplayName(value: String): String? = - value.trim().takeIf(String::isNotEmpty) - -internal fun normalizeServerUrl(value: String): String? { - val parsed = runCatching { URI(value.trim()) }.getOrNull() ?: return null - val scheme = parsed.scheme?.lowercase() ?: return null - val host = parsed.host?.lowercase() ?: return null - if (scheme != "http" && scheme != "https") return null - if (host.isEmpty() || parsed.userInfo != null || parsed.fragment != null) return null - - return runCatching { - URI( - scheme, - null, - host, - parsed.port, - parsed.path, - parsed.query, - null, - ).normalize().toASCIIString() - }.getOrNull() -} - -internal fun AccountsState.requireValid(): AccountsState { - require(accounts.map(Account::id).distinct().size == accounts.size) { - "Account IDs must be unique" - } - accounts.forEach { account -> - require(account.id.value.isNotBlank() && account.id.value == account.id.value.trim()) { - "Account IDs must be trimmed and nonblank" - } - require(normalizeDisplayName(account.displayName) == account.displayName) { - "Account display names must be trimmed and nonblank" - } - require(normalizeServerUrl(account.serverUrl) == account.serverUrl) { - "Account server URLs must be normalized and valid" - } - } - - if (accounts.isEmpty()) { - require(selectedAccountId == null) { - "Selection must be empty when there are no accounts" - } - } else { - require(selectedAccountId != null && accounts.any { it.id == selectedAccountId }) { - "Selection must identify an existing account" - } - } - require( - pendingRemovalAccountId == null || - accounts.any { it.id == pendingRemovalAccountId }, - ) { - "Pending removal must identify an existing account" - } - - return this -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountIdFactory.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountIdFactory.kt @@ -1,7 +0,0 @@ -package org.radroots.studio.accounts.state - -import org.radroots.studio.accounts.model.AccountId - -fun interface AccountIdFactory { - fun nextId(): AccountId -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt @@ -1,151 +0,0 @@ -package org.radroots.studio.accounts.state - -import org.radroots.studio.accounts.model.Account -import org.radroots.studio.accounts.model.AccountId -import org.radroots.studio.accounts.model.AccountsAction -import org.radroots.studio.accounts.model.AccountsProblem -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.model.AddAccountDraft -import org.radroots.studio.accounts.model.LoginStatus -import org.radroots.studio.accounts.model.normalizeDisplayName -import org.radroots.studio.accounts.model.normalizeServerUrl -import org.radroots.studio.accounts.model.requireValid - -class AccountsReducer( - private val accountIdFactory: AccountIdFactory, -) { - fun reduce( - state: AccountsState, - action: AccountsAction, - ): AccountsState { - state.requireValid() - val nextState = when (action) { - is AccountsAction.EditAddDisplayName -> state.copy( - addDraft = state.addDraft.copy(displayName = action.value), - ) - is AccountsAction.EditAddServerUrl -> state.copy( - addDraft = state.addDraft.copy(serverUrl = action.value), - ) - AccountsAction.SubmitAddAccount -> submitAddAccount(state) - is AccountsAction.SelectAccount -> selectAccount(state, action) - is AccountsAction.LogIn -> setLoginStatus( - state = state, - accountId = action.accountId, - loginStatus = LoginStatus.LoggedIn, - ) - is AccountsAction.LogOut -> setLoginStatus( - state = state, - accountId = action.accountId, - loginStatus = LoginStatus.LoggedOut, - ) - is AccountsAction.RequestRemoveAccount -> requestRemoval(state, action) - AccountsAction.CancelRemoveAccount -> state.copy( - pendingRemovalAccountId = null, - problem = null, - ) - is AccountsAction.ConfirmRemoveAccount -> confirmRemoval(state, action) - AccountsAction.DismissProblem -> state.copy(problem = null) - } - return nextState.requireValid() - } - - private fun submitAddAccount(state: AccountsState): AccountsState { - val displayName = normalizeDisplayName(state.addDraft.displayName) - ?: return state.copy(problem = AccountsProblem.BlankDisplayName) - val serverUrl = normalizeServerUrl(state.addDraft.serverUrl) - ?: return state.copy(problem = AccountsProblem.InvalidServerUrl) - val accountId = accountIdFactory.nextId() - if (accountId.value.isBlank() || accountId.value != accountId.value.trim()) { - return state.copy(problem = AccountsProblem.InvalidGeneratedAccountId) - } - if (state.accounts.any { it.id == accountId }) { - return state.copy(problem = AccountsProblem.DuplicateAccountId(accountId)) - } - - val account = Account( - id = accountId, - displayName = displayName, - serverUrl = serverUrl, - loginStatus = LoginStatus.LoggedOut, - ) - return state.copy( - accounts = state.accounts + account, - selectedAccountId = accountId, - addDraft = AddAccountDraft(), - pendingRemovalAccountId = null, - problem = null, - ) - } - - private fun selectAccount( - state: AccountsState, - action: AccountsAction.SelectAccount, - ): AccountsState = if (state.accounts.any { it.id == action.accountId }) { - state.copy( - selectedAccountId = action.accountId, - problem = null, - ) - } else { - state.copy(problem = AccountsProblem.AccountNotFound(action.accountId)) - } - - private fun setLoginStatus( - state: AccountsState, - accountId: AccountId, - loginStatus: LoginStatus, - ): AccountsState { - if (state.accounts.none { it.id == accountId }) { - return state.copy(problem = AccountsProblem.AccountNotFound(accountId)) - } - return state.copy( - accounts = state.accounts.map { account -> - if (account.id == accountId) { - account.copy(loginStatus = loginStatus) - } else { - account - } - }, - problem = null, - ) - } - - private fun requestRemoval( - state: AccountsState, - action: AccountsAction.RequestRemoveAccount, - ): AccountsState = if (state.accounts.any { it.id == action.accountId }) { - state.copy( - pendingRemovalAccountId = action.accountId, - problem = null, - ) - } else { - state.copy(problem = AccountsProblem.AccountNotFound(action.accountId)) - } - - private fun confirmRemoval( - state: AccountsState, - action: AccountsAction.ConfirmRemoveAccount, - ): AccountsState { - if (state.pendingRemovalAccountId != action.accountId) { - return state.copy( - problem = AccountsProblem.RemovalTargetMismatch( - expectedAccountId = state.pendingRemovalAccountId, - actualAccountId = action.accountId, - ), - ) - } - - val removedIndex = state.accounts.indexOfFirst { it.id == action.accountId } - val remainingAccounts = state.accounts.filterNot { it.id == action.accountId } - val selectedAccountId = if (state.selectedAccountId != action.accountId) { - state.selectedAccountId - } else { - remainingAccounts.getOrNull(removedIndex)?.id ?: remainingAccounts.lastOrNull()?.id - } - return state.copy( - accounts = remainingAccounts, - selectedAccountId = selectedAccountId, - pendingRemovalAccountId = null, - problem = null, - ) - } -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsStore.kt @@ -1,21 +0,0 @@ -package org.radroots.studio.accounts.state - -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import org.radroots.studio.accounts.model.AccountsAction -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.model.requireValid - -class AccountsStore( - initialState: AccountsState, - private val reducer: AccountsReducer, -) { - private val mutableState = mutableStateOf(initialState.requireValid()) - - val state: State<AccountsState> - get() = mutableState - - fun dispatch(action: AccountsAction) { - mutableState.value = reducer.reduce(mutableState.value, action) - } -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsScreen.kt @@ -1,273 +0,0 @@ -package org.radroots.studio.accounts.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import org.radroots.studio.accounts.model.Account -import org.radroots.studio.accounts.model.AccountsAction -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.model.AccountsProblem -import org.radroots.studio.accounts.model.LoginStatus - -private val WindowBackgroundColor = Color(0xFFF5F5F2) -private val ButtonBackgroundColor = Color(0xFFE7E7E2) -private val InputBackgroundColor = Color(0xFFFEFDF8) - -@Composable -fun AccountsScreen( - state: AccountsState, - onAction: (AccountsAction) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("accounts-screen"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("radroots") - BasicText("Accounts") - AddAccountForm(state = state, onAction = onAction) - - state.problem?.let { problem -> - BasicText( - text = problemMessage(problem), - modifier = Modifier.testTag("accounts-problem"), - ) - } - - if (state.accounts.isEmpty()) { - BasicText( - text = "No accounts yet.", - modifier = Modifier.testTag("accounts-empty"), - ) - } else { - Row( - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(24.dp), - ) { - LazyColumn( - modifier = Modifier - .weight(1f) - .testTag("accounts-list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items( - items = state.accounts, - key = { account -> account.id.value }, - ) { account -> - AccountRow( - account = account, - isSelected = account.id == state.selectedAccountId, - onSelect = { - onAction(AccountsAction.SelectAccount(account.id)) - }, - onLogIn = { - onAction(AccountsAction.LogIn(account.id)) - }, - onLogOut = { - onAction(AccountsAction.LogOut(account.id)) - }, - isPendingRemoval = account.id == state.pendingRemovalAccountId, - onRequestRemoval = { - onAction(AccountsAction.RequestRemoveAccount(account.id)) - }, - onConfirmRemoval = { - onAction(AccountsAction.ConfirmRemoveAccount(account.id)) - }, - onCancelRemoval = { - onAction(AccountsAction.CancelRemoveAccount) - }, - ) - } - } - - state.accounts - .firstOrNull { account -> account.id == state.selectedAccountId } - ?.let { account -> - AccountDetails( - account = account, - modifier = Modifier.weight(1f), - ) - } - } - } - } -} - -@Composable -private fun AddAccountForm( - state: AccountsState, - onAction: (AccountsAction) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - BasicText("Add account") - BasicTextField( - value = state.addDraft.displayName, - onValueChange = { value -> - onAction(AccountsAction.EditAddDisplayName(value)) - }, - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "Account display name" } - .testTag("add-display-name") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (state.addDraft.displayName.isEmpty()) { - BasicText("Display name") - } - innerTextField() - }, - ) - BasicTextField( - value = state.addDraft.serverUrl, - onValueChange = { value -> - onAction(AccountsAction.EditAddServerUrl(value)) - }, - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "Account server URL" } - .testTag("add-server-url") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (state.addDraft.serverUrl.isEmpty()) { - BasicText("Server URL") - } - innerTextField() - }, - ) - TextAction( - text = "Add Account", - testTag = "add-submit", - onClick = { onAction(AccountsAction.SubmitAddAccount) }, - ) - } -} - -@Composable -private fun TextAction( - text: String, - testTag: String, - onClick: () -> Unit, -) { - BasicText( - text = text, - modifier = Modifier - .semantics { role = Role.Button } - .testTag(testTag) - .clickable(onClick = onClick) - .background(ButtonBackgroundColor) - .padding(8.dp), - ) -} - -private fun problemMessage(problem: AccountsProblem): String = when (problem) { - AccountsProblem.BlankDisplayName -> "Display name is required." - AccountsProblem.InvalidServerUrl -> "Enter a valid HTTP or HTTPS server URL." - AccountsProblem.InvalidGeneratedAccountId -> "Could not create an account ID." - is AccountsProblem.DuplicateAccountId -> "That account ID already exists." - is AccountsProblem.AccountNotFound -> "That account no longer exists." - is AccountsProblem.RemovalTargetMismatch -> "The removal target changed." -} - -@Composable -private fun AccountRow( - account: Account, - isSelected: Boolean, - onSelect: () -> Unit, - onLogIn: () -> Unit, - onLogOut: () -> Unit, - isPendingRemoval: Boolean, - onRequestRemoval: () -> Unit, - onConfirmRemoval: () -> Unit, - onCancelRemoval: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .semantics { selected = isSelected } - .testTag("account-row:${account.id.value}") - .clickable(onClick = onSelect) - .padding(8.dp), - ) { - BasicText(account.displayName) - BasicText(account.serverUrl) - if (isSelected) { - BasicText( - text = "Selected", - modifier = Modifier.testTag("account-selected:${account.id.value}"), - ) - } - when (account.loginStatus) { - LoginStatus.LoggedOut -> TextAction( - text = "Login", - testTag = "account-login:${account.id.value}", - onClick = onLogIn, - ) - LoginStatus.LoggedIn -> TextAction( - text = "Logout", - testTag = "account-logout:${account.id.value}", - onClick = onLogOut, - ) - } - if (isPendingRemoval) { - BasicText("Remove this account?") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextAction( - text = "Confirm", - testTag = "remove-confirm", - onClick = onConfirmRemoval, - ) - TextAction( - text = "Cancel", - testTag = "remove-cancel", - onClick = onCancelRemoval, - ) - } - } else { - TextAction( - text = "Remove", - testTag = "account-remove:${account.id.value}", - onClick = onRequestRemoval, - ) - } - } -} - -@Composable -private fun AccountDetails( - account: Account, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicText("Selected account") - BasicText(account.displayName) - BasicText(account.serverUrl) - BasicText("Status: ${account.loginStatus.name}") - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/TestAccounts.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/TestAccounts.kt @@ -1,26 +0,0 @@ -package org.radroots.studio.accounts - -import org.radroots.studio.accounts.model.Account -import org.radroots.studio.accounts.model.AccountId -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.model.LoginStatus - -internal fun testAccount( - id: String = "account-1", - displayName: String = "Farm Account", - serverUrl: String = "https://farm.example.test", - loginStatus: LoginStatus = LoginStatus.LoggedOut, -) = Account( - id = AccountId(id), - displayName = displayName, - serverUrl = serverUrl, - loginStatus = loginStatus, -) - -internal fun testAccountsState( - vararg accounts: Account, - selectedAccountId: AccountId? = accounts.firstOrNull()?.id, -) = AccountsState( - accounts = accounts.toList(), - selectedAccountId = selectedAccountId, -) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/model/AccountsStateTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/model/AccountsStateTest.kt @@ -1,122 +0,0 @@ -package org.radroots.studio.accounts.model - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull -import org.radroots.studio.accounts.testAccount - -class AccountsStateTest { - @Test - fun defaultStateIsEmpty() { - val state = AccountsState() - - assertEquals(emptyList(), state.accounts) - assertNull(state.selectedAccountId) - assertEquals(AddAccountDraft(), state.addDraft) - assertNull(state.pendingRemovalAccountId) - assertNull(state.problem) - } - - @Test - fun accountContainsOnlyTheApprovedProofFields() { - val account = Account( - id = AccountId("account-1"), - displayName = "Farm Account", - serverUrl = "https://farm.example.test", - loginStatus = LoginStatus.LoggedOut, - ) - - assertEquals("account-1", account.id.value) - assertEquals("Farm Account", account.displayName) - assertEquals("https://farm.example.test", account.serverUrl) - assertEquals(LoginStatus.LoggedOut, account.loginStatus) - } - - @Test - fun validStatePassesInvariantValidation() { - val account = testAccount() - - assertEquals( - AccountsState( - accounts = listOf(account), - selectedAccountId = account.id, - ), - AccountsState( - accounts = listOf(account), - selectedAccountId = account.id, - ).requireValid(), - ) - } - - @Test - fun serverUrlNormalizationIsStableAndBounded() { - assertEquals( - "https://farm.example.test/b?mode=local", - normalizeServerUrl(" HTTPS://FARM.EXAMPLE.TEST/a/../b?mode=local "), - ) - assertEquals("http://localhost:8080", normalizeServerUrl("http://localhost:8080")) - assertNull(normalizeServerUrl("ftp://farm.example.test")) - assertNull(normalizeServerUrl("https:///missing-host")) - assertNull(normalizeServerUrl("https://user@farm.example.test")) - assertNull(normalizeServerUrl("https://farm.example.test/#fragment")) - assertNull(normalizeServerUrl("not a url")) - } - - @Test - fun invalidAccountValuesAreRejected() { - invalidAccounts().forEach { account -> - assertFailsWith<IllegalArgumentException> { - AccountsState( - accounts = listOf(account), - selectedAccountId = account.id, - ).requireValid() - } - } - } - - @Test - fun duplicateIdsAreRejected() { - val account = testAccount() - - assertFailsWith<IllegalArgumentException> { - AccountsState( - accounts = listOf(account, account.copy(displayName = "Second Account")), - selectedAccountId = account.id, - ).requireValid() - } - } - - @Test - fun danglingSelectionAndRemovalTargetsAreRejected() { - val account = testAccount() - val missingId = AccountId("missing") - - listOf( - AccountsState(accounts = listOf(account)), - AccountsState( - accounts = listOf(account), - selectedAccountId = missingId, - ), - AccountsState(selectedAccountId = missingId), - AccountsState( - accounts = listOf(account), - selectedAccountId = account.id, - pendingRemovalAccountId = missingId, - ), - ).forEach { state -> - assertFailsWith<IllegalArgumentException> { - state.requireValid() - } - } - } - - private fun invalidAccounts() = listOf( - testAccount().copy(id = AccountId("")), - testAccount().copy(id = AccountId(" account-1")), - testAccount().copy(displayName = ""), - testAccount().copy(displayName = " Farm Account"), - testAccount().copy(serverUrl = "ftp://farm.example.test"), - testAccount().copy(serverUrl = "HTTPS://FARM.EXAMPLE.TEST"), - ) -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt @@ -1,349 +0,0 @@ -package org.radroots.studio.accounts.state - -import java.util.ArrayDeque -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import org.radroots.studio.accounts.model.AccountId -import org.radroots.studio.accounts.model.AccountsAction -import org.radroots.studio.accounts.model.AccountsProblem -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.model.LoginStatus -import org.radroots.studio.accounts.model.requireValid -import org.radroots.studio.accounts.testAccount -import org.radroots.studio.accounts.testAccountsState - -class AccountsReducerTest { - @Test - fun draftEditsChangeOnlyTheRequestedField() { - val reducer = reducerWithIds("unused") - - val named = reducer.reduce( - AccountsState(), - AccountsAction.EditAddDisplayName("Farm Account"), - ) - val addressed = reducer.reduce( - named, - AccountsAction.EditAddServerUrl("https://farm.example.test"), - ) - - assertEquals("Farm Account", addressed.addDraft.displayName) - assertEquals("https://farm.example.test", addressed.addDraft.serverUrl) - assertEquals(emptyList(), addressed.accounts) - } - - @Test - fun validSubmitNormalizesAddsAndSelectsLoggedOutAccount() { - val factory = RecordingIdFactory("account-1") - val reducer = AccountsReducer(factory) - val state = draftState(" Farm Account ", " HTTPS://FARM.EXAMPLE.TEST/a/../b ") - - val result = reducer.reduce(state, AccountsAction.SubmitAddAccount) - - assertEquals(1, factory.callCount) - assertEquals(AccountId("account-1"), result.selectedAccountId) - assertEquals("Farm Account", result.accounts.single().displayName) - assertEquals("https://farm.example.test/b", result.accounts.single().serverUrl) - assertEquals(LoginStatus.LoggedOut, result.accounts.single().loginStatus) - assertEquals("", result.addDraft.displayName) - assertEquals("", result.addDraft.serverUrl) - assertEquals(null, result.problem) - } - - @Test - fun invalidDraftDoesNotRequestAnIdOrMutateAccounts() { - val factory = RecordingIdFactory("unused") - val reducer = AccountsReducer(factory) - val existing = testAccount() - val base = testAccountsState(existing) - - val blankName = reducer.reduce( - base.copy( - addDraft = base.addDraft.copy(serverUrl = "https://farm.example.test"), - ), - AccountsAction.SubmitAddAccount, - ) - val invalidUrl = reducer.reduce( - base.copy( - addDraft = base.addDraft.copy(displayName = "Second Account", serverUrl = "ftp://host"), - ), - AccountsAction.SubmitAddAccount, - ) - - assertEquals(0, factory.callCount) - assertEquals(base.accounts, blankName.accounts) - assertEquals(base.selectedAccountId, blankName.selectedAccountId) - assertIs<AccountsProblem.BlankDisplayName>(blankName.problem) - assertEquals(base.accounts, invalidUrl.accounts) - assertIs<AccountsProblem.InvalidServerUrl>(invalidUrl.problem) - } - - @Test - fun invalidOrDuplicateGeneratedIdIsAStableFailure() { - val existing = testAccount() - val base = testAccountsState(existing).copy( - addDraft = draftState("Second Account", "https://second.example.test").addDraft, - ) - - val blank = AccountsReducer(RecordingIdFactory(" ")).reduce( - base, - AccountsAction.SubmitAddAccount, - ) - val duplicate = AccountsReducer(RecordingIdFactory(existing.id.value)).reduce( - base, - AccountsAction.SubmitAddAccount, - ) - - assertEquals(base.accounts, blank.accounts) - assertIs<AccountsProblem.InvalidGeneratedAccountId>(blank.problem) - assertEquals(base.accounts, duplicate.accounts) - assertEquals(AccountsProblem.DuplicateAccountId(existing.id), duplicate.problem) - } - - @Test - fun dismissProblemClearsOnlyTheProblem() { - val state = testAccountsState(testAccount()).copy( - problem = AccountsProblem.InvalidServerUrl, - ) - - val result = reducerWithIds("unused").reduce( - state, - AccountsAction.DismissProblem, - ) - - assertEquals(state.copy(problem = null), result) - } - - @Test - fun selectionTargetsAnExplicitExistingAccount() { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val base = testAccountsState(first, second) - val reducer = reducerWithIds("unused") - - val selected = reducer.reduce( - base.copy(problem = AccountsProblem.InvalidServerUrl), - AccountsAction.SelectAccount(second.id), - ) - val missingId = AccountId("missing") - val missing = reducer.reduce( - selected, - AccountsAction.SelectAccount(missingId), - ) - - assertEquals(second.id, selected.selectedAccountId) - assertEquals(null, selected.problem) - assertEquals(second.id, missing.selectedAccountId) - assertEquals(AccountsProblem.AccountNotFound(missingId), missing.problem) - } - - @Test - fun loginAndLogoutUpdateOnlyTheExplicitAccount() { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val base = testAccountsState(first, second) - val reducer = reducerWithIds("unused") - - val loggedIn = reducer.reduce(base, AccountsAction.LogIn(second.id)) - val idempotentLogin = reducer.reduce(loggedIn, AccountsAction.LogIn(second.id)) - val loggedOut = reducer.reduce(idempotentLogin, AccountsAction.LogOut(second.id)) - val idempotentLogout = reducer.reduce(loggedOut, AccountsAction.LogOut(second.id)) - - assertEquals(LoginStatus.LoggedOut, loggedIn.accounts[0].loginStatus) - assertEquals(LoginStatus.LoggedIn, loggedIn.accounts[1].loginStatus) - assertEquals(loggedIn, idempotentLogin) - assertEquals(LoginStatus.LoggedOut, loggedOut.accounts[1].loginStatus) - assertEquals(loggedOut, idempotentLogout) - assertEquals(first.id, loggedOut.selectedAccountId) - } - - @Test - fun loginAndLogoutRejectMissingTargets() { - val base = testAccountsState(testAccount()) - val missingId = AccountId("missing") - val reducer = reducerWithIds("unused") - - listOf( - reducer.reduce(base, AccountsAction.LogIn(missingId)), - reducer.reduce(base, AccountsAction.LogOut(missingId)), - ).forEach { result -> - assertEquals(base.accounts, result.accounts) - assertEquals(base.selectedAccountId, result.selectedAccountId) - assertEquals(AccountsProblem.AccountNotFound(missingId), result.problem) - } - } - - @Test - fun removalRequestTargetsAnExistingAccountAndCanBeCancelled() { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val base = testAccountsState(first, second) - val reducer = reducerWithIds("unused") - - val firstRequest = reducer.reduce( - base, - AccountsAction.RequestRemoveAccount(first.id), - ) - val replacedRequest = reducer.reduce( - firstRequest, - AccountsAction.RequestRemoveAccount(second.id), - ) - val missingId = AccountId("missing") - val missingRequest = reducer.reduce( - replacedRequest, - AccountsAction.RequestRemoveAccount(missingId), - ) - val cancelled = reducer.reduce( - replacedRequest.copy(problem = AccountsProblem.InvalidServerUrl), - AccountsAction.CancelRemoveAccount, - ) - - assertEquals(first.id, firstRequest.pendingRemovalAccountId) - assertEquals(second.id, replacedRequest.pendingRemovalAccountId) - assertEquals(second.id, missingRequest.pendingRemovalAccountId) - assertEquals(AccountsProblem.AccountNotFound(missingId), missingRequest.problem) - assertEquals(null, cancelled.pendingRemovalAccountId) - assertEquals(null, cancelled.problem) - assertEquals(base.accounts, cancelled.accounts) - } - - @Test - fun confirmationRequiresThePendingExplicitTarget() { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val reducer = reducerWithIds("unused") - val base = testAccountsState(first, second) - - val withoutRequest = reducer.reduce( - base, - AccountsAction.ConfirmRemoveAccount(first.id), - ) - val requested = reducer.reduce( - base, - AccountsAction.RequestRemoveAccount(first.id), - ) - val mismatch = reducer.reduce( - requested, - AccountsAction.ConfirmRemoveAccount(second.id), - ) - - assertEquals( - AccountsProblem.RemovalTargetMismatch(null, first.id), - withoutRequest.problem, - ) - assertEquals(base.accounts, withoutRequest.accounts) - assertEquals( - AccountsProblem.RemovalTargetMismatch(first.id, second.id), - mismatch.problem, - ) - assertEquals(requested.accounts, mismatch.accounts) - assertEquals(first.id, mismatch.pendingRemovalAccountId) - } - - @Test - fun removingSelectedAccountChoosesTheDeterministicNeighbor() { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val third = testAccount(id = "account-3", displayName = "Third Account") - val reducer = reducerWithIds("unused") - - val removedFirst = reducer.removeSelected(testAccountsState(first, second, third), first.id) - val removedMiddle = reducer.removeSelected( - testAccountsState(first, second, third, selectedAccountId = second.id), - second.id, - ) - val removedFinal = reducer.removeSelected( - testAccountsState(first, second, third, selectedAccountId = third.id), - third.id, - ) - - assertEquals(listOf(second, third), removedFirst.accounts) - assertEquals(second.id, removedFirst.selectedAccountId) - assertEquals(listOf(first, third), removedMiddle.accounts) - assertEquals(third.id, removedMiddle.selectedAccountId) - assertEquals(listOf(first, second), removedFinal.accounts) - assertEquals(second.id, removedFinal.selectedAccountId) - } - - @Test - fun removingUnselectedOrFinalAccountPreservesValidSelection() { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val reducer = reducerWithIds("unused") - - val unselected = reducer.removeSelected(testAccountsState(first, second), second.id) - val finalAccount = reducer.removeSelected(testAccountsState(first), first.id) - - assertEquals(listOf(first), unselected.accounts) - assertEquals(first.id, unselected.selectedAccountId) - assertEquals(emptyList(), finalAccount.accounts) - assertEquals(null, finalAccount.selectedAccountId) - assertEquals(null, finalAccount.pendingRemovalAccountId) - } - - @Test - fun completeActionTracePreservesEveryStateInvariant() { - val reducer = reducerWithIds("account-1", "account-2") - val actions = listOf( - AccountsAction.EditAddDisplayName("First Account"), - AccountsAction.EditAddServerUrl("https://first.example.test"), - AccountsAction.SubmitAddAccount, - AccountsAction.EditAddDisplayName("Second Account"), - AccountsAction.EditAddServerUrl("https://second.example.test"), - AccountsAction.SubmitAddAccount, - AccountsAction.SelectAccount(AccountId("account-1")), - AccountsAction.LogIn(AccountId("account-1")), - AccountsAction.LogIn(AccountId("account-1")), - AccountsAction.LogOut(AccountId("account-1")), - AccountsAction.RequestRemoveAccount(AccountId("account-2")), - AccountsAction.CancelRemoveAccount, - AccountsAction.RequestRemoveAccount(AccountId("account-1")), - AccountsAction.ConfirmRemoveAccount(AccountId("account-1")), - AccountsAction.SelectAccount(AccountId("missing")), - AccountsAction.DismissProblem, - ) - - val result = actions.fold(AccountsState()) { state, action -> - reducer.reduce(state, action).requireValid() - } - - assertEquals(listOf(AccountId("account-2")), result.accounts.map { it.id }) - assertEquals(AccountId("account-2"), result.selectedAccountId) - assertEquals(null, result.problem) - } - - private fun draftState( - displayName: String, - serverUrl: String, - ) = AccountsState( - addDraft = org.radroots.studio.accounts.model.AddAccountDraft( - displayName = displayName, - serverUrl = serverUrl, - ), - ) - - private fun reducerWithIds(vararg ids: String) = - AccountsReducer(RecordingIdFactory(*ids)) - - private fun AccountsReducer.removeSelected( - state: AccountsState, - accountId: AccountId, - ): AccountsState { - val requested = reduce(state, AccountsAction.RequestRemoveAccount(accountId)) - return reduce(requested, AccountsAction.ConfirmRemoveAccount(accountId)) - } -} - -private class RecordingIdFactory( - vararg ids: String, -) : AccountIdFactory { - private val ids = ArrayDeque(ids.map(::AccountId)) - - var callCount = 0 - private set - - override fun nextId(): AccountId { - callCount += 1 - return ids.removeFirst() - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsStoreTest.kt @@ -1,75 +0,0 @@ -package org.radroots.studio.accounts.state - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import org.radroots.studio.accounts.model.AccountId -import org.radroots.studio.accounts.model.AccountsAction -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.testAccount - -class AccountsStoreTest { - @Test - fun storeExposesInitialStateAndDispatchesThroughReducer() { - val store = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer { AccountId("account-1") }, - ) - - assertEquals(AccountsState(), store.state.value) - - store.dispatch(AccountsAction.EditAddDisplayName("Farm Account")) - store.dispatch(AccountsAction.EditAddServerUrl("https://farm.example.test")) - store.dispatch(AccountsAction.SubmitAddAccount) - - assertEquals(AccountId("account-1"), store.state.value.selectedAccountId) - assertEquals("Farm Account", store.state.value.accounts.single().displayName) - } - - @Test - fun observedStateTracksSequentialDispatches() { - val store = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer { AccountId("account-1") }, - ) - val observedState = store.state - - store.dispatch(AccountsAction.EditAddDisplayName("Farm Account")) - assertEquals("Farm Account", observedState.value.addDraft.displayName) - store.dispatch(AccountsAction.EditAddServerUrl("https://farm.example.test")) - store.dispatch(AccountsAction.SubmitAddAccount) - store.dispatch(AccountsAction.LogIn(AccountId("account-1"))) - - assertEquals(store.state.value, observedState.value) - assertEquals(1, observedState.value.accounts.size) - } - - @Test - fun invalidInitialStateFailsAtConstruction() { - assertFailsWith<IllegalArgumentException> { - AccountsStore( - initialState = AccountsState(accounts = listOf(testAccount())), - reducer = AccountsReducer { AccountId("unused") }, - ) - } - } - - @Test - fun storeInstancesNeverShareState() { - val first = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer { AccountId("first") }, - ) - val second = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer { AccountId("second") }, - ) - - first.dispatch(AccountsAction.EditAddDisplayName("First Account")) - first.dispatch(AccountsAction.EditAddServerUrl("https://first.example.test")) - first.dispatch(AccountsAction.SubmitAddAccount) - - assertEquals(listOf(AccountId("first")), first.state.value.accounts.map { it.id }) - assertEquals(AccountsState(), second.state.value) - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsScreenTest.kt @@ -1,305 +0,0 @@ -package org.radroots.studio.accounts.ui - -import java.util.ArrayDeque -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import kotlin.test.Test -import kotlin.test.assertEquals -import org.radroots.studio.accounts.model.AccountsAction -import org.radroots.studio.accounts.model.AccountId -import org.radroots.studio.accounts.model.AccountsProblem -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.state.AccountsReducer -import org.radroots.studio.accounts.state.AccountsStore -import org.radroots.studio.accounts.model.LoginStatus -import org.radroots.studio.accounts.testAccount -import org.radroots.studio.accounts.testAccountsState - -@OptIn(ExperimentalTestApi::class) -class AccountsScreenTest { - @Test - fun emptyStateIsVisible() = runComposeUiTest { - setContent { - AccountsScreen( - state = testAccountsState(), - onAction = {}, - ) - } - - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("accounts-empty").assertIsDisplayed() - onNodeWithText("No accounts yet.").assertIsDisplayed() - } - - @Test - fun accountListShowsSelectionAndEmitsExplicitSelection() = runComposeUiTest { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val actions = mutableListOf<AccountsAction>() - setContent { - AccountsScreen( - state = testAccountsState(first, second), - onAction = actions::add, - ) - } - - onNodeWithTag("accounts-list").assertIsDisplayed() - onNodeWithTag("account-row:${first.id.value}").assertIsSelected() - onNodeWithTag( - testTag = "account-selected:${first.id.value}", - useUnmergedTree = true, - ).assertIsDisplayed() - onNodeWithTag("account-row:${second.id.value}").performClick() - - assertEquals( - listOf<AccountsAction>(AccountsAction.SelectAccount(second.id)), - actions, - ) - } - - @Test - fun addFormEmitsTypedDraftAndSubmitActions() = runComposeUiTest { - val actions = mutableListOf<AccountsAction>() - setContent { - var state by remember { mutableStateOf(AccountsState()) } - AccountsScreen( - state = state, - onAction = { action -> - actions.add(action) - state = when (action) { - is AccountsAction.EditAddDisplayName -> state.copy( - addDraft = state.addDraft.copy(displayName = action.value), - ) - is AccountsAction.EditAddServerUrl -> state.copy( - addDraft = state.addDraft.copy(serverUrl = action.value), - ) - else -> state - } - }, - ) - } - - onNodeWithTag("add-display-name").performTextInput("Farm Account") - onNodeWithTag("add-server-url").performTextInput("https://farm.example.test") - onNodeWithTag("add-submit").performClick() - - assertEquals( - listOf( - AccountsAction.EditAddDisplayName("Farm Account"), - AccountsAction.EditAddServerUrl("https://farm.example.test"), - AccountsAction.SubmitAddAccount, - ), - actions, - ) - } - - @Test - fun problemIsDisplayedWithoutChangingTheProvidedState() = runComposeUiTest { - setContent { - AccountsScreen( - state = AccountsState(problem = AccountsProblem.InvalidServerUrl), - onAction = {}, - ) - } - - onNodeWithTag("accounts-problem").assertIsDisplayed() - onNodeWithText("Enter a valid HTTP or HTTPS server URL.").assertIsDisplayed() - } - - @Test - fun integratedFormAddsAndSelectsAnAccount() = runComposeUiTest { - val store = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer { AccountId("account-1") }, - ) - setContent { - AccountsScreen( - state = store.state.value, - onAction = store::dispatch, - ) - } - - onNodeWithTag("add-display-name").performTextInput("Farm Account") - onNodeWithTag("add-server-url").performTextInput("https://farm.example.test") - onNodeWithTag("add-submit").performClick() - - onNodeWithTag("account-row:account-1").assertIsDisplayed() - onNodeWithTag("account-row:account-1").assertIsSelected() - } - - @Test - fun statusControlsEmitExplicitAccountActions() = runComposeUiTest { - val loggedOut = testAccount() - val loggedIn = testAccount( - id = "account-2", - displayName = "Second Account", - loginStatus = LoginStatus.LoggedIn, - ) - val actions = mutableListOf<AccountsAction>() - setContent { - AccountsScreen( - state = testAccountsState(loggedOut, loggedIn), - onAction = actions::add, - ) - } - - onNodeWithTag("account-login:${loggedOut.id.value}", useUnmergedTree = true) - .performClick() - onNodeWithTag("account-logout:${loggedIn.id.value}", useUnmergedTree = true) - .performClick() - - assertEquals( - listOf( - AccountsAction.LogIn(loggedOut.id), - AccountsAction.LogOut(loggedIn.id), - ), - actions, - ) - } - - @Test - fun integratedStatusControlUpdatesAvailableAction() = runComposeUiTest { - val account = testAccount() - val store = AccountsStore( - initialState = testAccountsState(account), - reducer = AccountsReducer { AccountId("unused") }, - ) - setContent { - AccountsScreen( - state = store.state.value, - onAction = store::dispatch, - ) - } - - onNodeWithTag("account-login:${account.id.value}", useUnmergedTree = true) - .performClick() - onNodeWithTag("account-logout:${account.id.value}", useUnmergedTree = true) - .assertIsDisplayed() - onNodeWithText("Status: LoggedIn").assertIsDisplayed() - } - - @Test - fun removalControlsEmitRequestCancelAndExplicitConfirmation() = runComposeUiTest { - val account = testAccount() - val actions = mutableListOf<AccountsAction>() - setContent { - var state by remember { mutableStateOf(testAccountsState(account)) } - AccountsScreen( - state = state, - onAction = { action -> - actions.add(action) - state = when (action) { - is AccountsAction.RequestRemoveAccount -> state.copy( - pendingRemovalAccountId = action.accountId, - ) - AccountsAction.CancelRemoveAccount -> state.copy( - pendingRemovalAccountId = null, - ) - else -> state - } - }, - ) - } - - onNodeWithTag("account-remove:${account.id.value}", useUnmergedTree = true) - .performClick() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - onNodeWithTag("account-remove:${account.id.value}", useUnmergedTree = true) - .performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - - assertEquals( - listOf( - AccountsAction.RequestRemoveAccount(account.id), - AccountsAction.CancelRemoveAccount, - AccountsAction.RequestRemoveAccount(account.id), - AccountsAction.ConfirmRemoveAccount(account.id), - ), - actions, - ) - } - - @Test - fun integratedRemovalCanBeCancelledThenConfirmed() = runComposeUiTest { - val first = testAccount() - val second = testAccount(id = "account-2", displayName = "Second Account") - val store = AccountsStore( - initialState = testAccountsState(first, second), - reducer = AccountsReducer { AccountId("unused") }, - ) - setContent { - AccountsScreen( - state = store.state.value, - onAction = store::dispatch, - ) - } - - onNodeWithTag("account-remove:${second.id.value}", useUnmergedTree = true) - .performClick() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - onNodeWithTag("account-row:${second.id.value}").assertIsDisplayed() - - onNodeWithTag("account-remove:${second.id.value}", useUnmergedTree = true) - .performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - - onAllNodesWithTag("account-row:${second.id.value}").assertCountEquals(0) - onNodeWithTag("account-row:${first.id.value}").assertIsSelected() - } - - @Test - fun integratedAccountJourneyReturnsToTheEmptyState() = runComposeUiTest { - val generatedIds = ArrayDeque(listOf(AccountId("account-1"), AccountId("account-2"))) - val store = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer { generatedIds.removeFirst() }, - ) - setContent { - AccountsScreen( - state = store.state.value, - onAction = store::dispatch, - ) - } - - onNodeWithTag("accounts-empty").assertIsDisplayed() - addAccount("First Account", "https://first.example.test") - addAccount("Second Account", "https://second.example.test") - - onNodeWithTag("account-row:account-1").performClick() - onNodeWithTag("account-row:account-1").assertIsSelected() - onNodeWithTag("account-login:account-1", useUnmergedTree = true).performClick() - onNodeWithTag("account-logout:account-1", useUnmergedTree = true).performClick() - - onNodeWithTag("account-remove:account-2", useUnmergedTree = true).performClick() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - onNodeWithTag("account-row:account-2").assertIsDisplayed() - onNodeWithTag("account-remove:account-2", useUnmergedTree = true).performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - - onNodeWithTag("account-remove:account-1", useUnmergedTree = true).performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - onNodeWithTag("accounts-empty").assertIsDisplayed() - onAllNodesWithTag("accounts-list").assertCountEquals(0) - } - - private fun androidx.compose.ui.test.ComposeUiTest.addAccount( - displayName: String, - serverUrl: String, - ) { - onNodeWithTag("add-display-name").performTextInput(displayName) - onNodeWithTag("add-server-url").performTextInput(serverUrl) - onNodeWithTag("add-submit").performClick() - } -} diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -539,7 +539,7 @@ handoff commit sequence. - [x] 53. Implement saved-account list, activation, and removal controls. - [x] 54. Implement active account home screen. - [x] 55. Implement switch account flow in UI. -- [ ] 56. Remove canonical Kotlin account reducer/store usage. +- [x] 56. Remove canonical Kotlin account reducer/store usage. - [ ] 57. Remove generic server-account remnants. ### RCLD-11