mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(onchain-zaps): inline Namecoin resolution indicator + result row
The local-cache suggestion dropdown can momentarily show a stale match when the user types a .bit identifier. For example, after resolving "m@testls.bit" earlier in the session, typing the bare host "testls.bit" causes findUsersStartingWith() to return the cached m@... profile first; the correct _@testls.bit profile only appears a few seconds later when ElectrumX resolution finishes. There is no visual hint that a Namecoin lookup is in flight, and on hard failures (timeout, malformed record, no nostr field, etc.) the user gets no feedback at all. This adds a dedicated NamecoinResolutionRow composable mounted between the recipient field and the local-cache dropdown: - "Resolving <name> on Namecoin…" spinner while the on-chain lookup is in flight (after a 300 ms debounce that matches the dropdown's own debounce). - On success, a tappable row showing the resolved profile with a distinct "Namecoin" badge (MaterialSymbols.Link), so the user can pick the on-chain-verified profile unambiguously. - On failure, a single explanatory error line covering all NamecoinResolveOutcome variants the resolver already produces: NameNotFound, NoNostrField, MalformedRecord (with the underlying parser error verbatim), ServersUnreachable, InvalidIdentifier and Timeout. State is held in the shared NamecoinResolveState sealed class already used by NamecoinNameService and the desktop SearchScreen — no new state model is introduced. A small mapOutcomeToResolveState() helper mirrors the same wording desktop ships, so all Namecoin surfaces produce the same diagnostic string for the same outcome. The row owns its own LaunchedEffect keyed on the typed query, so it cancels in-flight lookups whenever the user keeps typing, and it unmounts cleanly once a recipient is selected. It uses the existing Amethyst.instance.namecoinResolver instance, so no DI plumbing or new network calls beyond what was already wired up for .bit resolution. The dropdown is left untouched: local-cache results are still valid hits (just not necessarily the *intended* on-chain identity), so they remain available below the new row. Pure helpers (looksLikeNamecoinIdentifier + the outcome-to-state mapper) are covered by unit tests in NamecoinResolutionRowTest.
This commit is contained in:
+321
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Translate a [NamecoinResolveOutcome] from the quartz resolver into the
|
||||
* shared [NamecoinResolveState] used elsewhere in the app (e.g. the
|
||||
* desktop `SearchScreen`'s inline Namecoin lookup row).
|
||||
*
|
||||
* Mirrors the wording desktop already ships so the same identifier
|
||||
* produces the same diagnostic string regardless of which surface
|
||||
* triggered the lookup. Callers are expected to handle
|
||||
* [NamecoinResolveOutcome.Success] separately (it needs a [User]
|
||||
* lookup through [com.vitorpamplona.amethyst.model.LocalCache], which
|
||||
* this helper has no access to).
|
||||
*/
|
||||
fun mapOutcomeToResolveState(outcome: NamecoinResolveOutcome): NamecoinResolveState =
|
||||
when (outcome) {
|
||||
is NamecoinResolveOutcome.Success ->
|
||||
// Success is intentionally NOT handled here — callers must
|
||||
// resolve the pubkey through LocalCache first.
|
||||
error("mapOutcomeToResolveState called with Success outcome; resolve via LocalCache instead")
|
||||
|
||||
is NamecoinResolveOutcome.NameNotFound -> NamecoinResolveState.NotFound
|
||||
|
||||
is NamecoinResolveOutcome.NoNostrField ->
|
||||
NamecoinResolveState.Error("${outcome.name} is registered but has no Nostr pubkey")
|
||||
|
||||
is NamecoinResolveOutcome.MalformedRecord ->
|
||||
// Surface the parser detail verbatim so the publisher of the
|
||||
// broken record can locate the bad byte
|
||||
// (kotlinx.serialization includes a column number).
|
||||
NamecoinResolveState.Error("${outcome.name} record is malformed: ${outcome.error}")
|
||||
|
||||
is NamecoinResolveOutcome.ServersUnreachable ->
|
||||
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again")
|
||||
|
||||
is NamecoinResolveOutcome.InvalidIdentifier ->
|
||||
NamecoinResolveState.Error("Invalid Namecoin identifier")
|
||||
|
||||
NamecoinResolveOutcome.Timeout ->
|
||||
NamecoinResolveState.Error("Resolution timed out — servers may be slow, try again")
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight syntactic check: does this look like something we should
|
||||
* route to Namecoin? Mirrors [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver.isNamecoinIdentifier]
|
||||
* but tolerates a leading `@` (matches the dropdown's [com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState.userSearchTermOrNull]).
|
||||
*/
|
||||
fun looksLikeNamecoinIdentifier(raw: String): Boolean {
|
||||
val trimmed = raw.trim().removePrefix("@").lowercase()
|
||||
if (trimmed.length < 5) return false
|
||||
return trimmed.endsWith(".bit") ||
|
||||
trimmed.contains("@") && trimmed.substringAfter('@').endsWith(".bit")
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Namecoin resolution indicator + result row, sandwiched between
|
||||
* the recipient text field and the local-cache suggestion dropdown in
|
||||
* [OnchainZapSendDialog].
|
||||
*
|
||||
* Behaviour:
|
||||
* - Renders nothing when [searchInput] is not a `.bit` identifier.
|
||||
* - Shows a small spinner row ("Resolving on Namecoin…") while the
|
||||
* ElectrumX lookup is in flight (after a 300 ms debounce to match
|
||||
* the dropdown's own debounce).
|
||||
* - On success, shows the resolved user as a tappable row with a
|
||||
* `MaterialSymbols.Link` badge labelled "Namecoin"; tapping calls
|
||||
* [onUserResolved].
|
||||
* - On failure, shows a single explanatory line in the error colour.
|
||||
*
|
||||
* State is held in [NamecoinResolveState] (the same sealed class the
|
||||
* desktop `SearchScreen` and `NamecoinNameService` already use) so this
|
||||
* row stays in lockstep with the rest of the app's Namecoin UI.
|
||||
*
|
||||
* The composable is intentionally self-contained: it owns its own
|
||||
* [LaunchedEffect] keyed on [searchInput], so it cancels in-flight
|
||||
* lookups whenever the user keeps typing.
|
||||
*/
|
||||
@Composable
|
||||
fun NamecoinResolutionRow(
|
||||
searchInput: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
onUserResolved: (User) -> Unit,
|
||||
) {
|
||||
val trimmed = remember(searchInput) { searchInput.trim().removePrefix("@") }
|
||||
if (!looksLikeNamecoinIdentifier(trimmed)) return
|
||||
|
||||
var state by remember { mutableStateOf<NamecoinResolveState?>(null) }
|
||||
|
||||
LaunchedEffect(trimmed) {
|
||||
// Match UserSuggestionState's 300 ms debounce so we don't fire a
|
||||
// lookup on every keystroke.
|
||||
delay(300)
|
||||
state = NamecoinResolveState.Loading
|
||||
val outcome =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
Amethyst.instance.namecoinResolver.resolveDetailed(trimmed)
|
||||
}.getOrElse {
|
||||
NamecoinResolveOutcome.ServersUnreachable(
|
||||
it.message ?: it::class.simpleName ?: "Lookup error",
|
||||
)
|
||||
}
|
||||
}
|
||||
state =
|
||||
when (outcome) {
|
||||
is NamecoinResolveOutcome.Success -> NamecoinResolveState.Resolved(outcome.result)
|
||||
else -> mapOutcomeToResolveState(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
when (val s = state) {
|
||||
null, NamecoinResolveState.Loading -> ResolvingChip(trimmed)
|
||||
is NamecoinResolveState.Resolved -> ResolvedRow(trimmed, s, accountViewModel, onUserResolved)
|
||||
NamecoinResolveState.NotFound -> FailedRow("No record for $trimmed on Namecoin.")
|
||||
is NamecoinResolveState.Error -> FailedRow(s.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResolvingChip(query: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "Resolving $query on Namecoin…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResolvedRow(
|
||||
query: String,
|
||||
state: NamecoinResolveState.Resolved,
|
||||
accountViewModel: AccountViewModel,
|
||||
onUserResolved: (User) -> Unit,
|
||||
) {
|
||||
// Look up the User in the same cache the rest of the app uses, exactly
|
||||
// the way desktop's SearchScreen does. Falls back to a malformed-record
|
||||
// error row if the pubkey somehow fails the hex shape check.
|
||||
val user =
|
||||
remember(state.result.pubkey) {
|
||||
accountViewModel.account.cache.checkGetOrCreateUser(state.result.pubkey)
|
||||
}
|
||||
if (user == null) {
|
||||
FailedRow(
|
||||
"${state.result.namecoinName} record is malformed: " +
|
||||
"pubkey ${state.result.pubkey} is not a valid hex key",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onUserResolved(user) },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
size = 32.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = user.toBestDisplayName(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = query,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
NamecoinBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NamecoinBadge() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Link,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Namecoin",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailedRow(message: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.errorContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -414,6 +414,18 @@ private fun RecipientSection(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
// Inline Namecoin lookup feedback. Local-cache suggestions can race
|
||||
// ahead of the on-chain resolution (especially when the user has
|
||||
// resolved a sibling `user@<host>.bit` earlier in the session and the
|
||||
// current query is the bare host), so we surface the in-flight state +
|
||||
// the eventual on-chain match in its own row, distinct from the
|
||||
// generic dropdown. Failures are surfaced here too.
|
||||
NamecoinResolutionRow(
|
||||
searchInput = searchInput,
|
||||
accountViewModel = accountViewModel,
|
||||
onUserResolved = onSelectUser,
|
||||
)
|
||||
|
||||
if (searchInput.length > 2) {
|
||||
ShowUserSuggestionList(
|
||||
userSuggestions = userSuggestions,
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NamecoinResolutionRowTest {
|
||||
// ── looksLikeNamecoinIdentifier ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `bare bit hostname is a namecoin identifier`() {
|
||||
assertTrue(looksLikeNamecoinIdentifier("testls.bit"))
|
||||
assertTrue(looksLikeNamecoinIdentifier("Example.BIT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user at bit hostname is a namecoin identifier`() {
|
||||
assertTrue(looksLikeNamecoinIdentifier("m@testls.bit"))
|
||||
assertTrue(looksLikeNamecoinIdentifier("ALICE@example.BIT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leading at sign is tolerated like the dropdown does`() {
|
||||
assertTrue(looksLikeNamecoinIdentifier("@testls.bit"))
|
||||
assertTrue(looksLikeNamecoinIdentifier("@m@testls.bit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dns nip05 is not a namecoin identifier`() {
|
||||
assertFalse(looksLikeNamecoinIdentifier("alice@example.com"))
|
||||
assertFalse(looksLikeNamecoinIdentifier("example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `npub-shaped input is not a namecoin identifier`() {
|
||||
assertFalse(looksLikeNamecoinIdentifier("npub1w90qq8jq8x0z6nyz3vqgsk9vnp0w9p4ldwc0wq4xv2n7v8jch2nq3p6wrx"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short or empty input is not a namecoin identifier`() {
|
||||
// Below the minimum length threshold the dropdown also uses
|
||||
// (UserSuggestionState.userSearchTermOrNull requires >2 chars).
|
||||
assertFalse(looksLikeNamecoinIdentifier(""))
|
||||
assertFalse(looksLikeNamecoinIdentifier(".bit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-label bit name is still considered a namecoin identifier`() {
|
||||
// Namecoin allows single-character labels; "a.bit" is a valid
|
||||
// (if expensive) registration. Don't filter it out client-side.
|
||||
assertTrue(looksLikeNamecoinIdentifier("a.bit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bit substring elsewhere does not trigger`() {
|
||||
// "habit" or "rabbit.example.com" shouldn't match.
|
||||
assertFalse(looksLikeNamecoinIdentifier("rabbit.example.com"))
|
||||
assertFalse(looksLikeNamecoinIdentifier("ihabit"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at without bit suffix does not match`() {
|
||||
// "foo@bar" with no .bit on the right side should not match.
|
||||
assertFalse(looksLikeNamecoinIdentifier("foo@bar"))
|
||||
assertFalse(looksLikeNamecoinIdentifier("foo@bar.com"))
|
||||
}
|
||||
|
||||
// ── mapOutcomeToResolveState ───────────────────────────────────────────
|
||||
// Reuses the shared NamecoinResolveState already used by
|
||||
// NamecoinNameService and the desktop SearchScreen so all surfaces
|
||||
// produce the same diagnostic strings for the same outcome.
|
||||
|
||||
@Test
|
||||
fun `name not found maps to NotFound`() {
|
||||
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NameNotFound("d/testls"))
|
||||
assertSame(NamecoinResolveState.NotFound, state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no nostr field maps to Error and mentions the name`() {
|
||||
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.NoNostrField("d/noname"))
|
||||
require(state is NamecoinResolveState.Error)
|
||||
assertTrue(state.message.contains("d/noname"))
|
||||
assertTrue(state.message.contains("Nostr"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed record preserves underlying parser detail`() {
|
||||
val state =
|
||||
mapOutcomeToResolveState(
|
||||
NamecoinResolveOutcome.MalformedRecord(
|
||||
"d/broken",
|
||||
"Unfinished JSON term at EOF at line 1, column 474",
|
||||
),
|
||||
)
|
||||
require(state is NamecoinResolveState.Error)
|
||||
assertTrue(state.message.contains("d/broken"))
|
||||
assertTrue(state.message.contains("Unfinished JSON"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `servers unreachable maps to a generic Error`() {
|
||||
val state =
|
||||
mapOutcomeToResolveState(NamecoinResolveOutcome.ServersUnreachable("Connection refused"))
|
||||
require(state is NamecoinResolveState.Error)
|
||||
assertTrue(state.message.contains("ElectrumX"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid identifier maps to a generic Error`() {
|
||||
val state =
|
||||
mapOutcomeToResolveState(NamecoinResolveOutcome.InvalidIdentifier("not_a_name"))
|
||||
require(state is NamecoinResolveState.Error)
|
||||
assertEquals("Invalid Namecoin identifier", state.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timeout maps to a timeout Error`() {
|
||||
val state = mapOutcomeToResolveState(NamecoinResolveOutcome.Timeout)
|
||||
require(state is NamecoinResolveState.Error)
|
||||
assertTrue(state.message.contains("timed out"))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun `success outcome must be handled by callers, not mapOutcomeToResolveState`() {
|
||||
// mapOutcomeToResolveState is documented as failure-only; callers must
|
||||
// route NamecoinResolveOutcome.Success through LocalCache themselves.
|
||||
mapOutcomeToResolveState(
|
||||
NamecoinResolveOutcome.Success(
|
||||
NamecoinNostrResult(
|
||||
pubkey = "deadbeef".repeat(8),
|
||||
relays = emptyList(),
|
||||
namecoinName = "d/testls",
|
||||
localPart = "_",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user