mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts: # cli/tests/.gitignore
This commit is contained in:
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.quartz.experimental.graperank
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* [GrapeRankCrawler.authorityOf] is the key the crawl's timeout-eviction counts
|
||||
* on. It must collapse the many per-user path URLs the outbox model mints for one
|
||||
* server into a single host, WITHOUT folding a distinct sibling host (e.g. a
|
||||
* `filter.` subdomain) into its parent.
|
||||
*/
|
||||
class GrapeRankAuthorityTest {
|
||||
private fun auth(url: String) = GrapeRankCrawler.authorityOf(url)
|
||||
|
||||
@Test
|
||||
fun bareHostIsItsOwnAuthority() {
|
||||
assertEquals("relay.damus.io", auth("wss://relay.damus.io"))
|
||||
assertEquals("relay.damus.io", auth("wss://relay.damus.io/"))
|
||||
assertEquals("nos.lol", auth("ws://nos.lol"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perUserPathUrlsOnOneHostCollapseToOneAuthority() {
|
||||
val a = auth("wss://filter.nostr.wine/npub1aaaa?broadcast=true")
|
||||
val b = auth("wss://filter.nostr.wine/npub1bbbb?broadcast=true&global=all")
|
||||
val c = auth("wss://filter.nostr.wine/?global=all")
|
||||
assertEquals("filter.nostr.wine", a)
|
||||
assertEquals(a, b)
|
||||
assertEquals(a, c)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterSubdomainIsNotFoldedIntoBareHost() {
|
||||
// nostr.wine reads are open; filter.nostr.wine is a different server that may
|
||||
// stall — evicting one must never take out the other.
|
||||
assertNotEquals(auth("wss://filter.nostr.wine/npub1x"), auth("wss://nostr.wine"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun portIsPartOfTheAuthority() {
|
||||
assertEquals("relay.veganostr.com:443", auth("wss://relay.veganostr.com:443/npub1z"))
|
||||
assertEquals("81.68.170.122:7114", auth("ws://81.68.170.122:7114/"))
|
||||
assertNotEquals(auth("wss://example.com:443"), auth("wss://example.com:8080"))
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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.quartz.experimental.graperank
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GrapeRankTest {
|
||||
private val obs = "observer"
|
||||
|
||||
private fun graphOf(edges: List<Triple<HexKey, HexKey, TrustRelation>>): TrustGraph {
|
||||
val b = TrustGraphBuilder()
|
||||
for ((source, target, relation) in edges) {
|
||||
when (relation) {
|
||||
TrustRelation.FOLLOW -> b.addFollows(source, listOf(target))
|
||||
TrustRelation.MUTE -> b.addMutes(source, listOf(target))
|
||||
TrustRelation.REPORT -> b.addReports(source, listOf(target))
|
||||
}
|
||||
}
|
||||
return b.build()
|
||||
}
|
||||
|
||||
private fun graphOf(vararg edges: Triple<HexKey, HexKey, TrustRelation>) = graphOf(edges.toList())
|
||||
|
||||
/** Score for a pubkey (0.0 if absent from the graph). */
|
||||
private fun DoubleArray.of(
|
||||
graph: TrustGraph,
|
||||
pubkey: HexKey,
|
||||
): Double {
|
||||
val id = graph.idOf(pubkey)
|
||||
return if (id < 0) 0.0 else this[id]
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observerIsPinnedAtFullSelfTrust() {
|
||||
val graph = graphOf(Triple(obs, "a", TrustRelation.FOLLOW))
|
||||
val scores = GrapeRank().compute(graph, obs)
|
||||
assertEquals(1.0, scores.of(graph, obs), 1e-12)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directFollowMatchesHandComputedValue() {
|
||||
val graph = graphOf(Triple(obs, "a", TrustRelation.FOLLOW))
|
||||
val scores = GrapeRank().compute(graph, obs)
|
||||
// weight = 0.5 * 1.0 * 0.85 = 0.425 ; score = conf(0.425) = 0.2551612...
|
||||
assertEquals(0.25516127, scores.of(graph, "a"), 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustDecaysSteeplyAcrossHops() {
|
||||
val graph =
|
||||
graphOf(
|
||||
Triple(obs, "a", TrustRelation.FOLLOW),
|
||||
Triple("a", "b", TrustRelation.FOLLOW),
|
||||
)
|
||||
val scores = GrapeRank().compute(graph, obs)
|
||||
val a = scores.of(graph, "a")
|
||||
val b = scores.of(graph, "b")
|
||||
assertEquals(0.004499, b, 1e-5)
|
||||
assertTrue(b < a / 10.0, "two-hop trust should be far below one-hop trust")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMuteFromAnEndorsedUserLowersTheScore() {
|
||||
val followOnlyGraph = graphOf(Triple(obs, "b", TrustRelation.FOLLOW))
|
||||
val followOnly = GrapeRank().compute(followOnlyGraph, obs).of(followOnlyGraph, "b")
|
||||
|
||||
val muteGraph =
|
||||
graphOf(
|
||||
Triple(obs, "a", TrustRelation.FOLLOW),
|
||||
Triple(obs, "b", TrustRelation.FOLLOW),
|
||||
Triple("a", "b", TrustRelation.MUTE),
|
||||
)
|
||||
val withMute = GrapeRank().compute(muteGraph, obs).of(muteGraph, "b")
|
||||
|
||||
assertTrue(withMute < followOnly, "a mute from a trusted user should pull b below the follow-only baseline")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun purelyReportedUserFloorsAtZero() {
|
||||
val graph =
|
||||
graphOf(
|
||||
Triple(obs, "a", TrustRelation.FOLLOW),
|
||||
Triple("a", "d", TrustRelation.REPORT),
|
||||
)
|
||||
val scores = GrapeRank().compute(graph, obs)
|
||||
assertEquals(0.0, scores.of(graph, "d"), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreachableUsersAreNotScored() {
|
||||
val graph =
|
||||
graphOf(
|
||||
Triple(obs, "a", TrustRelation.FOLLOW),
|
||||
Triple("x", "y", TrustRelation.FOLLOW),
|
||||
)
|
||||
val scores = GrapeRank().compute(graph, obs)
|
||||
assertTrue(scores.of(graph, "a") > 0.0)
|
||||
assertEquals(0.0, scores.of(graph, "y"), 1e-12, "a user with no path from the observer stays 0")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cyclesConverge() {
|
||||
val graph =
|
||||
graphOf(
|
||||
Triple(obs, "a", TrustRelation.FOLLOW),
|
||||
Triple("a", "b", TrustRelation.FOLLOW),
|
||||
Triple("b", "a", TrustRelation.FOLLOW),
|
||||
)
|
||||
val scores = GrapeRank().compute(graph, obs)
|
||||
assertTrue(scores.of(graph, "a") > 0.0)
|
||||
assertTrue(scores.of(graph, "b") > 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicatesRepeatedReportEdges() {
|
||||
// Two report edges a->d collapse to one; the score matches a single report.
|
||||
val once = graphOf(Triple(obs, "a", TrustRelation.FOLLOW), Triple("a", "d", TrustRelation.REPORT))
|
||||
val twice =
|
||||
graphOf(
|
||||
Triple(obs, "a", TrustRelation.FOLLOW),
|
||||
Triple("a", "d", TrustRelation.REPORT),
|
||||
Triple("a", "d", TrustRelation.REPORT),
|
||||
)
|
||||
assertEquals(2, twice.edgeCount(), "duplicate report edge should be dropped")
|
||||
assertEquals(
|
||||
GrapeRank().compute(once, obs).of(once, "d"),
|
||||
GrapeRank().compute(twice, obs).of(twice, "d"),
|
||||
1e-12,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adversarial cross-check: the worklist propagation must reach the same fixed
|
||||
* point as a naive full-sweep (the reference `v1FullSweep`) on random graphs.
|
||||
*/
|
||||
@Test
|
||||
fun worklistMatchesFullSweepOnRandomGraphs() {
|
||||
val params = GrapeRankParams(convergence = 1e-10)
|
||||
val engine = GrapeRank(params)
|
||||
repeat(50) { seed ->
|
||||
val rng = Random(seed)
|
||||
val n = 3 + rng.nextInt(12)
|
||||
val nodes = (0 until n).map { "u$it" }
|
||||
val edges = ArrayList<Triple<HexKey, HexKey, TrustRelation>>()
|
||||
for (src in nodes) {
|
||||
for (dst in nodes) {
|
||||
if (src == dst) continue
|
||||
if (rng.nextDouble() < 0.25) {
|
||||
val relation =
|
||||
when (rng.nextInt(5)) {
|
||||
0 -> TrustRelation.MUTE
|
||||
1 -> TrustRelation.REPORT
|
||||
else -> TrustRelation.FOLLOW
|
||||
}
|
||||
edges.add(Triple(src, dst, relation))
|
||||
}
|
||||
}
|
||||
}
|
||||
val observer = nodes.first()
|
||||
val graph = graphOf(edges)
|
||||
val scores = engine.compute(graph, observer)
|
||||
val reference = fullSweep(edges, nodes, observer, params)
|
||||
|
||||
for (node in nodes) {
|
||||
if (node == observer) continue // observer self-trust is not part of a ranking
|
||||
val a = scores.of(graph, node)
|
||||
val b = reference[node] ?: 0.0
|
||||
assertEquals(b, a, 1e-5, "seed=$seed node=$node worklist=$a fullSweep=$b")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference: blind full sweep over every user until nothing changes.
|
||||
private fun fullSweep(
|
||||
edges: List<Triple<HexKey, HexKey, TrustRelation>>,
|
||||
nodes: List<HexKey>,
|
||||
observer: HexKey,
|
||||
params: GrapeRankParams,
|
||||
): Map<HexKey, Double> {
|
||||
// Dedup identical edges (mirrors the builder: report edges dedup; follow/mute
|
||||
// sets are unique per source anyway).
|
||||
val incoming = HashMap<HexKey, MutableSet<Pair<HexKey, TrustRelation>>>()
|
||||
for ((s, t, r) in edges) {
|
||||
if (s == t) continue
|
||||
incoming.getOrPut(t) { LinkedHashSet() }.add(s to r)
|
||||
}
|
||||
|
||||
fun confidence(
|
||||
r: TrustRelation,
|
||||
source: HexKey,
|
||||
) = when (r) {
|
||||
TrustRelation.FOLLOW -> if (source == observer) params.directFollowConfidence else params.indirectFollowConfidence
|
||||
TrustRelation.MUTE -> params.muteConfidence
|
||||
TrustRelation.REPORT -> params.reportConfidence
|
||||
}
|
||||
|
||||
fun weightToConfidence(w: Double) = 1.0 - exp(-w * -ln(params.rigor))
|
||||
|
||||
val scores = HashMap<HexKey, Double>()
|
||||
scores[observer] = 1.0
|
||||
do {
|
||||
var changed = false
|
||||
for (target in nodes) {
|
||||
if (target == observer) continue
|
||||
var sumW = 0.0
|
||||
var sumWR = 0.0
|
||||
for ((source, r) in incoming[target] ?: emptySet()) {
|
||||
val s = scores[source] ?: continue
|
||||
val w = confidence(r, source) * s * params.attenuation
|
||||
sumW += w
|
||||
sumWR += w * r.rating
|
||||
}
|
||||
val newScore = if (abs(sumW) < 0.00001) 0.0 else max(weightToConfidence(sumW) * sumWR / sumW, 0.0)
|
||||
val old = scores.put(target, newScore) ?: 0.0
|
||||
changed = changed || abs(newScore - old) > params.convergence
|
||||
}
|
||||
} while (changed)
|
||||
return scores
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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.quartz.experimental.graperank
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TrustGraphBuilderTest {
|
||||
private val alice = "alice"
|
||||
private val bob = "bob"
|
||||
private val carol = "carol"
|
||||
private val dave = "dave"
|
||||
|
||||
/** Decode a node's incoming edges back to (source, relation) pairs from the CSR. */
|
||||
private fun TrustGraph.incomingOf(pubkey: HexKey): Set<Pair<HexKey, TrustRelation>> {
|
||||
val t = idOf(pubkey)
|
||||
if (t < 0) return emptySet()
|
||||
val out = HashSet<Pair<HexKey, TrustRelation>>()
|
||||
var i = inOffsets[t]
|
||||
val end = inOffsets[t + 1]
|
||||
while (i < end) {
|
||||
val packed = inPacked[i]
|
||||
val source = pubkeyOf(packed and TrustGraph.SOURCE_MASK)
|
||||
val relation = TrustRelation.entries.first { it.code == (packed ushr TrustGraph.SOURCE_BITS) }
|
||||
out.add(source to relation)
|
||||
i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildsFollowMuteAndReportEdges() {
|
||||
val b = TrustGraphBuilder()
|
||||
b.addFollows(alice, listOf(bob, carol))
|
||||
b.addMutes(bob, listOf(dave))
|
||||
b.addReports(carol, listOf(dave))
|
||||
val graph = b.build()
|
||||
|
||||
assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(bob))
|
||||
assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(carol))
|
||||
assertEquals(
|
||||
setOf(bob to TrustRelation.MUTE, carol to TrustRelation.REPORT),
|
||||
graph.incomingOf(dave),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsSelfEdges() {
|
||||
val b = TrustGraphBuilder()
|
||||
b.addFollows(alice, listOf(alice, bob))
|
||||
val graph = b.build()
|
||||
assertTrue(graph.incomingOf(alice).isEmpty(), "a self-follow must not become an edge")
|
||||
assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(bob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dedupesRepeatedReports() {
|
||||
val b = TrustGraphBuilder()
|
||||
b.addReports(alice, listOf(dave))
|
||||
b.addReports(alice, listOf(dave))
|
||||
val graph = b.build()
|
||||
assertEquals(1, graph.edgeCount())
|
||||
assertEquals(setOf(alice to TrustRelation.REPORT), graph.incomingOf(dave))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsFollowAndMuteFromSameSourceAsDistinctEdges() {
|
||||
val b = TrustGraphBuilder()
|
||||
b.addFollows(alice, listOf(bob))
|
||||
b.addMutes(alice, listOf(bob))
|
||||
val graph = b.build()
|
||||
assertEquals(
|
||||
setOf(alice to TrustRelation.FOLLOW, alice to TrustRelation.MUTE),
|
||||
graph.incomingOf(bob),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun internsEachPubkeyOnce() {
|
||||
val b = TrustGraphBuilder()
|
||||
b.addFollows(alice, listOf(bob, carol))
|
||||
b.addFollows(bob, listOf(carol))
|
||||
val graph = b.build()
|
||||
assertEquals(3, graph.nodeCount, "alice, bob, carol interned once each")
|
||||
assertEquals(3, graph.edgeCount())
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.client.accessories
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DrainFailureTest {
|
||||
// Non-failure and non-"cannot" terminals are never dead signals.
|
||||
@Test
|
||||
fun nonFailureTerminalsAreNull() {
|
||||
assertNull(classifyDrainFailure("eose"))
|
||||
assertNull(classifyDrainFailure("closed:duplicate: sub"))
|
||||
assertNull(classifyDrainFailure("timeout"))
|
||||
}
|
||||
|
||||
// A READ timeout (or generic post-handshake timeout) is alive-but-slow: never
|
||||
// dead. Measured 67% of these relays were reachable when re-probed fresh.
|
||||
@Test
|
||||
fun readTimeoutsStayRetryable() {
|
||||
assertNull(classifyDrainFailure("cannot:Read timed out (SocketTimeoutException)"))
|
||||
assertNull(classifyDrainFailure("cannot:timeout (SocketTimeoutException)"))
|
||||
}
|
||||
|
||||
// An HTTP 429 rate-limit is alive and will serve us after backoff: never dead.
|
||||
// Measured 4/4 such relays reachable when re-probed fresh.
|
||||
@Test
|
||||
fun rateLimitStaysRetryable() {
|
||||
assertNull(classifyDrainFailure("cannot:Server Misconfigured. Response: 429 Too Many Requests (ProtocolException)"))
|
||||
}
|
||||
|
||||
// Failing to ESTABLISH the connection is dead (0/30 reachable fresh). "connect
|
||||
// timed out" must be caught as DEAD and NOT slip into the read-timeout branch.
|
||||
@Test
|
||||
fun connectEstablishmentFailuresAreDead() {
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connect timed out (SocketTimeoutException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Unexpected response code for CONNECT: (IOException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connection refused (ConnectException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Failed to connect to /1.2.3.4:443"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:No route to host (NoRouteToHostException)"))
|
||||
}
|
||||
|
||||
// DNS and TLS misconfig can never work: DEAD.
|
||||
@Test
|
||||
fun dnsAndTlsAreDead() {
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Unable to resolve host (UnknownHostException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Received fatal alert: unrecognized_name (SSLHandshakeException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:PKIX path building failed: certificate (CertificateException)"))
|
||||
}
|
||||
|
||||
// Every other bad HTTP upgrade won't serve us this run (measured 503 0%, 502 20%
|
||||
// reachable; 402/403 gated; 200 not a relay) — DEAD, dropped on the first strike.
|
||||
@Test
|
||||
fun deadOrGatedHttpUpgradesAreDead() {
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. not a websocket"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 503 Service Unavailable (ProtocolException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 502 Bad Gateway (ProtocolException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 402 Payment Required (ProtocolException)"))
|
||||
}
|
||||
|
||||
// A mid-stream reset won't hand us events this run either: DEAD.
|
||||
@Test
|
||||
fun midStreamResetIsDead() {
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connection reset (SocketException)"))
|
||||
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Broken pipe (SocketException)"))
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.relay.client.pool
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PoolEventOutboxStateTest {
|
||||
private val relay = NormalizedRelayUrl("wss://relay.example/")
|
||||
|
||||
private fun fakeEvent() =
|
||||
Event(
|
||||
id = "0".repeat(64),
|
||||
pubKey = "0".repeat(64),
|
||||
createdAt = 0L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun authRequiredResponseDoesNotConsumeTryBudget() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
// Simulate 5 `auth-required:` responses — relay keeps challenging while
|
||||
// RelayAuthenticator signs + sends AUTH events asynchronously. None of
|
||||
// these should be counted against the 3-response try cap.
|
||||
repeat(5) {
|
||||
state.newResponse(relay, success = false, message = "auth-required: please authenticate")
|
||||
}
|
||||
|
||||
// Even after a follow-up newTry, the relay must remain in the outbox so
|
||||
// syncFilters() can re-publish once AUTH succeeds.
|
||||
state.newTry(relay)
|
||||
assertContains(state.relaysLeft(), relay)
|
||||
assertFalse(state.isDone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun regularRejectionStillBoundedByTryCap() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
// 3 non-AUTH rejections accumulate normally.
|
||||
repeat(3) {
|
||||
state.newResponse(relay, success = false, message = "error: rate limited")
|
||||
}
|
||||
state.newTry(relay)
|
||||
|
||||
// After the 4th newTry (with 3 prior responses already in flight), the
|
||||
// Tries cap kicks in and the relay is dropped from the outbox.
|
||||
assertFalse(state.relaysLeft().contains(relay))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalRejectionImmediatelyDropsRelay() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
state.newResponse(relay, success = false, message = "invalid: malformed event")
|
||||
|
||||
assertFalse(state.relaysLeft().contains(relay))
|
||||
assertTrue(state.isDone())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successDropsRelayFromOutbox() {
|
||||
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
|
||||
|
||||
state.newResponse(relay, success = true, message = "")
|
||||
|
||||
assertEquals(emptySet(), state.relaysLeft())
|
||||
assertTrue(state.isDone())
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -72,13 +72,15 @@ class BasicRelayClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onFailureWithMessageKeepsExistingFormat() {
|
||||
fun onFailureWithMessageAppendsExceptionClassName() {
|
||||
val (socket, listener) = connectAndCapture()
|
||||
|
||||
socket.onFailure(Exception("Connection reset"), null, null)
|
||||
|
||||
// The exception type is appended so listeners can classify the failure by
|
||||
// its stable class name rather than by localized message text.
|
||||
assertEquals(
|
||||
listOf("WebSocket Failure: Connection reset"),
|
||||
listOf("WebSocket Failure: Connection reset (Exception)"),
|
||||
listener.cannotConnectMessages,
|
||||
)
|
||||
}
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AuthorsMissingOutboxTest : BaseDBTest() {
|
||||
@Test
|
||||
fun emptyStoreReturnsNoAuthors() =
|
||||
forEachDB { db ->
|
||||
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorWithEventButNoOutboxIsMissing() =
|
||||
forEachDB { db ->
|
||||
val signer = NostrSignerSync()
|
||||
db.insert(signer.sign(TextNoteEvent.build("hello")))
|
||||
|
||||
assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorWithOutboxIsNotMissing() =
|
||||
forEachDB { db ->
|
||||
val hasOutbox = NostrSignerSync()
|
||||
val noOutbox = NostrSignerSync()
|
||||
|
||||
// Both authors have content; only one advertises a 10002.
|
||||
db.insert(hasOutbox.sign(TextNoteEvent.build("with relays")))
|
||||
db.insert(AdvertisedRelayListEvent.create(emptyList(), hasOutbox))
|
||||
db.insert(noOutbox.sign(TextNoteEvent.build("no relays")))
|
||||
|
||||
assertEquals(setOf(noOutbox.pubKey), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorKnownOnlyByTheirOutboxIsNotMissing() =
|
||||
forEachDB { db ->
|
||||
// The only stored event for this author IS the 10002. They must
|
||||
// not appear (the outer scan sees them, the NOT EXISTS excludes
|
||||
// them) — the anti-join is symmetric on the same table.
|
||||
val signer = NostrSignerSync()
|
||||
db.insert(AdvertisedRelayListEvent.create(emptyList(), signer))
|
||||
|
||||
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outboxDeletedMakesAuthorMissingAgain() =
|
||||
forEachDB { db ->
|
||||
val signer = NostrSignerSync()
|
||||
db.insert(signer.sign(TextNoteEvent.build("content")))
|
||||
val relayList = AdvertisedRelayListEvent.create(emptyList(), signer)
|
||||
db.insert(relayList)
|
||||
|
||||
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
|
||||
|
||||
// NIP-09: the author deletes their own relay list. No 10002 row
|
||||
// remains, so the anti-join reports them as missing again.
|
||||
db.insert(signer.sign(DeletionEvent.build(listOf(relayList))))
|
||||
|
||||
assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun giftWrapSenderIsNotCountedAsAuthor() =
|
||||
forEachDB { db ->
|
||||
val noteAuthor = NostrSignerSync()
|
||||
db.insert(noteAuthor.sign(TextNoteEvent.build("hi")))
|
||||
|
||||
// A kind-1059 giftwrap stores an ephemeral one-time key as its
|
||||
// pubkey (the real recipient is only a hash). It has no outbox and
|
||||
// never will — but it must NOT be reported as "missing" one, or the
|
||||
// result set would grow by one junk key per received DM.
|
||||
val ephemeralSender = "aa".repeat(32)
|
||||
db.insert(
|
||||
EventFactory.create("bb".repeat(32), ephemeralSender, 1L, GiftWrapEvent.KIND, emptyArray(), "", "00".repeat(64)),
|
||||
)
|
||||
|
||||
assertEquals(setOf(noteAuthor.pubKey), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixOfAuthorsReportsOnlyThoseWithoutOutbox() =
|
||||
forEachDB { db ->
|
||||
val a = NostrSignerSync()
|
||||
val b = NostrSignerSync()
|
||||
val c = NostrSignerSync()
|
||||
|
||||
db.insert(a.sign(TextNoteEvent.build("a1")))
|
||||
db.insert(a.sign(TextNoteEvent.build("a2")))
|
||||
db.insert(AdvertisedRelayListEvent.create(emptyList(), a))
|
||||
|
||||
db.insert(b.sign(TextNoteEvent.build("b1")))
|
||||
|
||||
db.insert(c.sign(TextNoteEvent.build("c1")))
|
||||
db.insert(AdvertisedRelayListEvent.create(emptyList(), c))
|
||||
|
||||
assertEquals(setOf(b.pubKey), db.authorsMissingOutbox().toSet())
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.quartz.nip59Giftwrap.wraps
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* NIP-17 relay-hint placement contract.
|
||||
*
|
||||
* Per NIP-17 §Publishing, the gift wrap's `p` tag MAY carry the recipient's
|
||||
* primary DM inbox relay as a third element so other devices of the recipient
|
||||
* can discover the wrap without a separate kind:10050 lookup. The hint
|
||||
* deliberately lives on the public wrap, NOT on the encrypted seal — putting
|
||||
* it on the seal would hide the routing information inside the encryption
|
||||
* envelope, defeating the purpose.
|
||||
*/
|
||||
class GiftWrapRelayHintTest {
|
||||
private val recipient = KeyPair()
|
||||
|
||||
private fun innerEvent(): Event {
|
||||
val signer = NostrSignerSync(KeyPair())
|
||||
return signer.sign(
|
||||
createdAt = 0L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "hello",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsToNoRelayHintForBackwardsCompat() =
|
||||
runTest {
|
||||
// Existing callers that don't pass a hint must continue to emit the
|
||||
// historical ["p", recipientPubKey] two-element tag shape.
|
||||
val wrap =
|
||||
GiftWrapEvent.create(
|
||||
event = innerEvent(),
|
||||
recipientPubKey = recipient.pubKey.toHexKey(),
|
||||
)
|
||||
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
|
||||
assertEquals(2, pTag.size, "p tag must be 2 elements when no hint passed")
|
||||
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayHintLandsOnWrapPTagAsThirdElement() =
|
||||
runTest {
|
||||
// When a hint is passed, it must appear as the THIRD element of the
|
||||
// wrap's p tag — NIP-17 spec. Not inside the encrypted seal.
|
||||
val hint = NormalizedRelayUrl("wss://dm.relay.example/")
|
||||
val wrap =
|
||||
GiftWrapEvent.create(
|
||||
event = innerEvent(),
|
||||
recipientPubKey = recipient.pubKey.toHexKey(),
|
||||
recipientRelayHint = hint,
|
||||
)
|
||||
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
|
||||
assertEquals(3, pTag.size, "p tag carries [tag, pubkey, relay-hint]")
|
||||
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
|
||||
assertEquals(hint.url, pTag[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentHintDoesNotAddTrailingEmptyElement() =
|
||||
runTest {
|
||||
// Defensive: a null hint must not produce `["p", pubkey, ""]` — that
|
||||
// would be a leak (broadcasts the user has no canonical inbox) and
|
||||
// a wire-format change from the historical shape.
|
||||
val wrap =
|
||||
GiftWrapEvent.create(
|
||||
event = innerEvent(),
|
||||
recipientPubKey = recipient.pubKey.toHexKey(),
|
||||
recipientRelayHint = null,
|
||||
)
|
||||
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
|
||||
assertNull(pTag.getOrNull(2), "third element must be absent, not empty string")
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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.quartz.utils.concurrent
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConcurrentCollectionsTest {
|
||||
@Test
|
||||
fun mapGetSet() {
|
||||
val m = ConcurrentMap<String, Int>()
|
||||
assertNull(m["a"])
|
||||
m["a"] = 1
|
||||
assertEquals(1, m["a"])
|
||||
m["a"] = 2
|
||||
assertEquals(2, m["a"])
|
||||
assertEquals(1, m.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapGetOrPutComputesOnce() {
|
||||
val m = ConcurrentMap<String, Int>()
|
||||
var calls = 0
|
||||
assertEquals(
|
||||
7,
|
||||
m.getOrPut("k") {
|
||||
calls++
|
||||
7
|
||||
},
|
||||
)
|
||||
// Present now: the default must NOT be recomputed.
|
||||
assertEquals(
|
||||
7,
|
||||
m.getOrPut("k") {
|
||||
calls++
|
||||
99
|
||||
},
|
||||
)
|
||||
assertEquals(1, calls)
|
||||
assertEquals(7, m["k"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapMergeInsertsThenCombines() {
|
||||
val m = ConcurrentMap<String, Int>()
|
||||
// Absent -> inserts the value verbatim, remap not applied.
|
||||
assertEquals(1, m.merge("k", 1) { a, b -> a + b })
|
||||
// Present -> remap(existing, value).
|
||||
assertEquals(4, m.merge("k", 3) { a, b -> a + b })
|
||||
assertEquals(4, m["k"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapSnapshotIsDetached() {
|
||||
val m = ConcurrentMap<String, Int>()
|
||||
m["a"] = 1
|
||||
m["b"] = 2
|
||||
val snap = m.snapshot()
|
||||
assertEquals(mapOf("a" to 1, "b" to 2), snap)
|
||||
// Mutating the map after the snapshot must not change the snapshot.
|
||||
m["c"] = 3
|
||||
assertEquals(2, snap.size)
|
||||
assertEquals(3, m.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setAddContainsSize() {
|
||||
val s = ConcurrentSet<String>()
|
||||
assertFalse("x" in s)
|
||||
assertTrue(s.add("x"))
|
||||
// Re-adding is a no-op and reports it.
|
||||
assertFalse(s.add("x"))
|
||||
assertTrue("x" in s)
|
||||
assertTrue(s.add("y"))
|
||||
assertEquals(2, s.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setSnapshotIsDetached() {
|
||||
val s = ConcurrentSet<String>()
|
||||
s.add("a")
|
||||
val snap = s.snapshot()
|
||||
s.add("b")
|
||||
assertEquals(setOf("a"), snap)
|
||||
assertEquals(2, s.size())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user