// Go reference: golang/nats-server/server/sublist_test.go
// Ports Go sublist tests not yet covered by SubListTests.cs or the SubList/ subfolder.
using NATS.Server.Subscriptions;
namespace NATS.Server.Core.Tests;
///
/// Go parity tests for SubList ported from sublist_test.go.
/// Covers basic multi-token matching, wildcard removal, cache eviction,
/// subject-validity helpers, queue results, reverse match, HasInterest,
/// NumInterest, and cache hit-rate statistics.
///
public class SubListGoParityTests
{
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static Subscription MakeSub(string subject, string? queue = null, string sid = "1")
=> new() { Subject = subject, Queue = queue, Sid = sid };
// =========================================================================
// Basic insert / match
// =========================================================================
///
/// Single-token subject round-trips through insert and match.
/// Ref: TestSublistInit / TestSublistInsertCount (sublist_test.go:117,122)
///
[Fact]
public void Init_count_is_zero_and_grows_with_inserts()
{
var sl = new SubList();
sl.Count.ShouldBe(0u);
sl.Insert(MakeSub("foo", sid: "1"));
sl.Insert(MakeSub("bar", sid: "2"));
sl.Insert(MakeSub("foo.bar", sid: "3"));
sl.Count.ShouldBe(3u);
}
///
/// A multi-token literal subject matches itself exactly.
/// Ref: TestSublistSimpleMultiTokens (sublist_test.go:154)
///
[Fact]
public void Simple_multi_token_match()
{
var sl = new SubList();
var sub = MakeSub("foo.bar.baz");
sl.Insert(sub);
var r = sl.Match("foo.bar.baz");
r.PlainSubs.ShouldHaveSingleItem();
r.PlainSubs[0].ShouldBeSameAs(sub);
}
///
/// A partial wildcard at the end of a pattern matches the final literal token.
/// Ref: TestSublistPartialWildcardAtEnd (sublist_test.go:190)
///
[Fact]
public void Partial_wildcard_at_end_matches_final_token()
{
var sl = new SubList();
var lsub = MakeSub("a.b.c", sid: "1");
var psub = MakeSub("a.b.*", sid: "2");
sl.Insert(lsub);
sl.Insert(psub);
var r = sl.Match("a.b.c");
r.PlainSubs.Length.ShouldBe(2);
r.PlainSubs.ShouldContain(lsub);
r.PlainSubs.ShouldContain(psub);
}
///
/// Subjects with two tokens do not match a single-token subscription.
/// Ref: TestSublistTwoTokenPubMatchSingleTokenSub (sublist_test.go:749)
///
[Fact]
public void Two_token_pub_does_not_match_single_token_sub()
{
var sl = new SubList();
var sub = MakeSub("foo");
sl.Insert(sub);
sl.Match("foo").PlainSubs.ShouldHaveSingleItem();
sl.Match("foo.bar").PlainSubs.ShouldBeEmpty();
}
// =========================================================================
// Removal with wildcards
// =========================================================================
///
/// Removing wildcard subscriptions decrements the count and clears match results.
/// Ref: TestSublistRemoveWildcard (sublist_test.go:255)
///
[Fact]
public void Remove_wildcard_subscriptions()
{
var sl = new SubList();
var sub = MakeSub("a.b.c.d", sid: "1");
var psub = MakeSub("a.b.*.d", sid: "2");
var fsub = MakeSub("a.b.>", sid: "3");
sl.Insert(sub);
sl.Insert(psub);
sl.Insert(fsub);
sl.Count.ShouldBe(3u);
sl.Match("a.b.c.d").PlainSubs.Length.ShouldBe(3);
sl.Remove(sub);
sl.Count.ShouldBe(2u);
sl.Remove(fsub);
sl.Count.ShouldBe(1u);
sl.Remove(psub);
sl.Count.ShouldBe(0u);
sl.Match("a.b.c.d").PlainSubs.ShouldBeEmpty();
}
///
/// Inserting a subscription with a wildcard literal token (e.g. "foo.*-") and
/// then removing it leaves the list empty and no spurious match on "foo.bar".
/// Ref: TestSublistRemoveWithWildcardsAsLiterals (sublist_test.go:789)
///
[Theory]
[InlineData("foo.*-")]
[InlineData("foo.>-")]
public void Remove_with_wildcard_as_literal(string subject)
{
var sl = new SubList();
var sub = MakeSub(subject);
sl.Insert(sub);
// Removing a non-existent subscription does nothing
sl.Remove(MakeSub("foo.bar"));
sl.Count.ShouldBe(1u);
sl.Remove(sub);
sl.Count.ShouldBe(0u);
}
// =========================================================================
// Cache behaviour
// =========================================================================
///
/// After inserting three subscriptions, adding a new wildcard subscription
/// invalidates the cached result and subsequent matches include the new sub.
/// Ref: TestSublistCache (sublist_test.go:423)
///
[Fact]
public void Cache_invalidated_by_subsequent_inserts()
{
var sl = new SubList();
var sub = MakeSub("a.b.c.d", sid: "1");
var psub = MakeSub("a.b.*.d", sid: "2");
var fsub = MakeSub("a.b.>", sid: "3");
sl.Insert(sub);
sl.Match("a.b.c.d").PlainSubs.ShouldHaveSingleItem();
sl.Insert(psub);
sl.Insert(fsub);
sl.Count.ShouldBe(3u);
var r = sl.Match("a.b.c.d");
r.PlainSubs.Length.ShouldBe(3);
r.PlainSubs.ShouldContain(sub);
r.PlainSubs.ShouldContain(psub);
r.PlainSubs.ShouldContain(fsub);
sl.Remove(sub);
sl.Remove(fsub);
sl.Remove(psub);
sl.Count.ShouldBe(0u);
// Cache is cleared by each removal (generation bump), but a subsequent Match
// may re-populate it with an empty result — verify no matching subs are found.
sl.Match("a.b.c.d").PlainSubs.ShouldBeEmpty();
}
///
/// Inserting a fwc sub after cache has been primed causes the next match to
/// return all three matching subs.
/// Ref: TestSublistCache (wildcard part) (sublist_test.go:465)
///
[Fact]
public void Cache_updated_when_new_wildcard_inserted()
{
var sl = new SubList();
sl.Insert(MakeSub("foo.*", sid: "1"));
sl.Insert(MakeSub("foo.bar", sid: "2"));
sl.Match("foo.baz").PlainSubs.ShouldHaveSingleItem();
sl.Match("foo.bar").PlainSubs.Length.ShouldBe(2);
sl.Insert(MakeSub("foo.>", sid: "3"));
sl.Match("foo.bar").PlainSubs.Length.ShouldBe(3);
}
[Fact]
public void Cache_generation_bump_rebuilds_match_result_after_insert_and_remove()
{
var sl = new SubList();
var exact = MakeSub("foo.bar", sid: "1");
var wildcard = MakeSub("foo.*", sid: "2");
sl.Insert(exact);
var first = sl.Match("foo.bar");
var second = sl.Match("foo.bar");
ReferenceEquals(first, second).ShouldBeTrue();
first.PlainSubs.Select(sub => sub.Sid).ShouldBe(["1"]);
sl.Insert(wildcard);
var afterInsert = sl.Match("foo.bar");
ReferenceEquals(afterInsert, first).ShouldBeFalse();
afterInsert.PlainSubs.Select(sub => sub.Sid).OrderBy(x => x).ToArray().ShouldBe(["1", "2"]);
sl.Remove(wildcard);
var afterRemove = sl.Match("foo.bar");
ReferenceEquals(afterRemove, afterInsert).ShouldBeFalse();
afterRemove.PlainSubs.Select(sub => sub.Sid).ShouldBe(["1"]);
}
///
/// Empty result is a shared singleton — two calls that yield no matches return
/// the same object reference.
/// Ref: TestSublistSharedEmptyResult (sublist_test.go:1049)
///
[Fact]
public void Empty_result_is_shared_singleton()
{
var sl = new SubList();
var r1 = sl.Match("foo");
var r2 = sl.Match("bar");
r1.PlainSubs.ShouldBeEmpty();
r2.PlainSubs.ShouldBeEmpty();
ReferenceEquals(r1, r2).ShouldBeTrue();
}
// =========================================================================
// Queue subscriptions
// =========================================================================
///
/// After inserting two queue groups, adding a plain sub makes it visible
/// in PlainSubs; adding more members to each group expands QueueSubs.
/// Removing members correctly shrinks group counts.
/// Ref: TestSublistBasicQueueResults (sublist_test.go:486)
///
[Fact]
public void Basic_queue_results_lifecycle()
{
var sl = new SubList();
const string subject = "foo";
var sub = MakeSub(subject, sid: "plain");
var sub1 = MakeSub(subject, queue: "bar", sid: "q1");
var sub2 = MakeSub(subject, queue: "baz", sid: "q2");
var sub3 = MakeSub(subject, queue: "bar", sid: "q3");
var sub4 = MakeSub(subject, queue: "baz", sid: "q4");
sl.Insert(sub1);
var r = sl.Match(subject);
r.PlainSubs.ShouldBeEmpty();
r.QueueSubs.Length.ShouldBe(1);
sl.Insert(sub2);
r = sl.Match(subject);
r.QueueSubs.Length.ShouldBe(2);
sl.Insert(sub);
r = sl.Match(subject);
r.PlainSubs.ShouldHaveSingleItem();
r.QueueSubs.Length.ShouldBe(2);
sl.Insert(sub3);
sl.Insert(sub4);
r = sl.Match(subject);
r.PlainSubs.ShouldHaveSingleItem();
r.QueueSubs.Length.ShouldBe(2);
// Each group should have 2 members
r.QueueSubs.ShouldAllBe(g => g.Length == 2);
// Remove the plain sub
sl.Remove(sub);
r = sl.Match(subject);
r.PlainSubs.ShouldBeEmpty();
r.QueueSubs.Length.ShouldBe(2);
// Remove one member from "bar" group
sl.Remove(sub1);
r = sl.Match(subject);
r.QueueSubs.Length.ShouldBe(2); // both groups still present
// Remove remaining "bar" member
sl.Remove(sub3);
r = sl.Match(subject);
r.QueueSubs.Length.ShouldBe(1); // only "baz" group remains
// Remove both "baz" members
sl.Remove(sub2);
sl.Remove(sub4);
r = sl.Match(subject);
r.PlainSubs.ShouldBeEmpty();
r.QueueSubs.ShouldBeEmpty();
}
// =========================================================================
// Subject validity helpers
// =========================================================================
///
/// IsValidPublishSubject (IsLiteral) rejects wildcard tokens and partial-wildcard
/// embedded in longer tokens is treated as a literal.
/// Ref: TestSublistValidLiteralSubjects (sublist_test.go:585)
///
[Theory]
[InlineData("foo", true)]
[InlineData(".foo", false)]
[InlineData("foo.", false)]
[InlineData("foo..bar", false)]
[InlineData("foo.bar.*", false)]
[InlineData("foo.bar.>", false)]
[InlineData("*", false)]
[InlineData(">", false)]
[InlineData("foo*", true)] // embedded * not a wildcard
[InlineData("foo**", true)]
[InlineData("foo.**", true)]
[InlineData("foo*bar", true)]
[InlineData("foo.*bar", true)]
[InlineData("foo*.bar", true)]
[InlineData("*bar", true)]
[InlineData("foo>", true)]
[InlineData("foo>>", true)]
[InlineData("foo.>>", true)]
[InlineData("foo>bar", true)]
[InlineData("foo.>bar", true)]
[InlineData("foo>.bar", true)]
[InlineData(">bar", true)]
public void IsValidPublishSubject_cases(string subject, bool expected)
{
// Ref: TestSublistValidLiteralSubjects (sublist_test.go:585)
SubjectMatch.IsValidPublishSubject(subject).ShouldBe(expected);
}
///
/// IsValidSubject accepts subjects with embedded wildcard characters
/// that are not standalone tokens, and rejects subjects with empty tokens.
/// Ref: TestSublistValidSubjects (sublist_test.go:612)
///
[Theory]
[InlineData(".", false)]
[InlineData(".foo", false)]
[InlineData("foo.", false)]
[InlineData("foo..bar", false)]
[InlineData(">.bar", false)]
[InlineData("foo.>.bar", false)]
[InlineData("foo", true)]
[InlineData("foo.bar.*", true)]
[InlineData("foo.bar.>", true)]
[InlineData("*", true)]
[InlineData(">", true)]
[InlineData("foo*", true)]
[InlineData("foo**", true)]
[InlineData("foo.**", true)]
[InlineData("foo*bar", true)]
[InlineData("foo.*bar", true)]
[InlineData("foo*.bar", true)]
[InlineData("*bar", true)]
[InlineData("foo>", true)]
[InlineData("foo>>", true)]
[InlineData("foo.>>", true)]
[InlineData("foo>bar", true)]
[InlineData("foo.>bar", true)]
[InlineData("foo>.bar", true)]
[InlineData(">bar", true)]
public void IsValidSubject_cases(string subject, bool expected)
{
// Ref: TestSublistValidSubjects (sublist_test.go:612)
SubjectMatch.IsValidSubject(subject).ShouldBe(expected);
}
///
/// IsLiteral correctly identifies subjects with embedded wildcard characters
/// (but not standalone wildcard tokens) as literal.
/// Ref: TestSubjectIsLiteral (sublist_test.go:673)
///
[Theory]
[InlineData("foo", true)]
[InlineData("foo.bar", true)]
[InlineData("foo*.bar", true)]
[InlineData("*", false)]
[InlineData(">", false)]
[InlineData("foo.*", false)]
[InlineData("foo.>", false)]
[InlineData("foo.*.>", false)]
[InlineData("foo.*.bar", false)]
[InlineData("foo.bar.>", false)]
public void IsLiteral_cases(string subject, bool expected)
{
// Ref: TestSubjectIsLiteral (sublist_test.go:673)
SubjectMatch.IsLiteral(subject).ShouldBe(expected);
}
///
/// MatchLiteral handles embedded wildcard-chars-as-literals correctly.
/// Ref: TestSublistMatchLiterals (sublist_test.go:644)
///
[Theory]
[InlineData("foo", "foo", true)]
[InlineData("foo", "bar", false)]
[InlineData("foo", "*", true)]
[InlineData("foo", ">", true)]
[InlineData("foo.bar", ">", true)]
[InlineData("foo.bar", "foo.>", true)]
[InlineData("foo.bar", "bar.>", false)]
[InlineData("stats.test.22", "stats.>", true)]
[InlineData("stats.test.22", "stats.*.*", true)]
[InlineData("foo.bar", "foo", false)]
[InlineData("stats.test.foos","stats.test.foos",true)]
[InlineData("stats.test.foos","stats.test.foo", false)]
[InlineData("stats.test", "stats.test.*", false)]
[InlineData("stats.test.foos","stats.*", false)]
[InlineData("stats.test.foos","stats.*.*.foos", false)]
// Embedded wildcard chars treated as literals
[InlineData("*bar", "*bar", true)]
[InlineData("foo*", "foo*", true)]
[InlineData("foo*bar", "foo*bar", true)]
[InlineData("foo.***.bar", "foo.***.bar", true)]
[InlineData(">bar", ">bar", true)]
[InlineData("foo>", "foo>", true)]
[InlineData("foo>bar", "foo>bar", true)]
[InlineData("foo.>>>.bar", "foo.>>>.bar", true)]
public void MatchLiteral_extended_cases(string literal, string pattern, bool expected)
{
// Ref: TestSublistMatchLiterals (sublist_test.go:644)
SubjectMatch.MatchLiteral(literal, pattern).ShouldBe(expected);
}
// =========================================================================
// Subject collide / subset
// =========================================================================
///
/// SubjectsCollide correctly identifies whether two subject patterns can
/// match the same literal subject.
/// Ref: TestSublistSubjectCollide (sublist_test.go:1548)
///
[Theory]
[InlineData("foo.*", "foo.*.bar.>", false)]
[InlineData("foo.*.bar.>", "foo.*", false)]
[InlineData("foo.*", "foo.foo", true)]
[InlineData("foo.*", "*.foo", true)]
[InlineData("foo.bar.>", "*.bar.foo", true)]
public void SubjectsCollide_cases(string s1, string s2, bool expected)
{
// Ref: TestSublistSubjectCollide (sublist_test.go:1548)
SubjectMatch.SubjectsCollide(s1, s2).ShouldBe(expected);
}
// =========================================================================
// tokenAt (0-based in .NET vs 1-based in Go)
// =========================================================================
///
/// TokenAt returns the nth dot-separated token (0-based in .NET).
/// The Go tokenAt helper uses 1-based indexing with "" for index 0; the .NET
/// port uses 0-based indexing throughout.
/// Ref: TestSubjectToken (sublist_test.go:707)
///
[Theory]
[InlineData("foo.bar.baz.*", 0, "foo")]
[InlineData("foo.bar.baz.*", 1, "bar")]
[InlineData("foo.bar.baz.*", 2, "baz")]
[InlineData("foo.bar.baz.*", 3, "*")]
[InlineData("foo.bar.baz.*", 4, "")] // out of range
public void TokenAt_zero_based(string subject, int index, string expected)
{
// Ref: TestSubjectToken (sublist_test.go:707)
SubjectMatch.TokenAt(subject, index).ToString().ShouldBe(expected);
}
// =========================================================================
// Stats / cache hit rate
// =========================================================================
///
/// Cache hit rate is computed correctly after 4 Match calls on the same subject
/// (first call misses, subsequent three hit the cache).
/// Ref: TestSublistAddCacheHitRate (sublist_test.go:1556)
///
[Fact]
public void Cache_hit_rate_is_computed_correctly()
{
var sl = new SubList();
sl.Insert(MakeSub("foo"));
for (var i = 0; i < 4; i++)
sl.Match("foo");
// 4 calls total, first is a cache miss, next 3 hit → 3/4 = 0.75
var stats = sl.Stats();
stats.CacheHitRate.ShouldBe(0.75, 1e-9);
}
///
/// Stats.NumCache is 0 when cache is empty (no matches have been performed yet).
/// Ref: TestSublistNoCacheStats (sublist_test.go:1064)
///
[Fact]
public void Stats_NumCache_reflects_cache_population()
{
var sl = new SubList();
sl.Insert(MakeSub("foo", sid: "1"));
sl.Insert(MakeSub("bar", sid: "2"));
sl.Insert(MakeSub("baz", sid: "3"));
sl.Insert(MakeSub("foo.bar.baz", sid: "4"));
// No matches performed yet — cache should be empty
sl.Stats().NumCache.ShouldBe(0u);
sl.Match("a.b.c");
sl.Match("bar");
// Two distinct subjects have been matched, so cache should have 2 entries
sl.Stats().NumCache.ShouldBe(2u);
}
// =========================================================================
// HasInterest
// =========================================================================
///
/// HasInterest returns true for subjects with matching subscriptions and false
/// otherwise, including after removal. Wildcard subscriptions match correctly.
/// Ref: TestSublistHasInterest (sublist_test.go:1609)
///
[Fact]
public void HasInterest_with_plain_and_wildcard_subs()
{
var sl = new SubList();
var fooSub = MakeSub("foo", sid: "1");
sl.Insert(fooSub);
sl.HasInterest("foo").ShouldBeTrue();
sl.HasInterest("bar").ShouldBeFalse();
sl.Remove(fooSub);
sl.HasInterest("foo").ShouldBeFalse();
// Partial wildcard
var pwcSub = MakeSub("foo.*", sid: "2");
sl.Insert(pwcSub);
sl.HasInterest("foo").ShouldBeFalse();
sl.HasInterest("foo.bar").ShouldBeTrue();
sl.HasInterest("foo.bar.baz").ShouldBeFalse();
sl.Remove(pwcSub);
sl.HasInterest("foo.bar").ShouldBeFalse();
// Full wildcard
var fwcSub = MakeSub("foo.>", sid: "3");
sl.Insert(fwcSub);
sl.HasInterest("foo").ShouldBeFalse();
sl.HasInterest("foo.bar").ShouldBeTrue();
sl.HasInterest("foo.bar.baz").ShouldBeTrue();
sl.Remove(fwcSub);
sl.HasInterest("foo.bar").ShouldBeFalse();
sl.HasInterest("foo.bar.baz").ShouldBeFalse();
}
///
/// HasInterest handles queue subscriptions: a queue sub creates interest
/// even though PlainSubs is empty.
/// Ref: TestSublistHasInterest (queue part) (sublist_test.go:1682)
///
[Fact]
public void HasInterest_with_queue_subscriptions()
{
var sl = new SubList();
var qsub = MakeSub("foo", queue: "bar", sid: "1");
var qsub2 = MakeSub("foo", queue: "baz", sid: "2");
sl.Insert(qsub);
sl.HasInterest("foo").ShouldBeTrue();
sl.HasInterest("foo.bar").ShouldBeFalse();
sl.Insert(qsub2);
sl.HasInterest("foo").ShouldBeTrue();
sl.Remove(qsub);
sl.HasInterest("foo").ShouldBeTrue(); // qsub2 still present
sl.Remove(qsub2);
sl.HasInterest("foo").ShouldBeFalse();
}
///
/// HasInterest correctly handles overlapping subscriptions where a literal
/// subject coexists with a wildcard at the same level.
/// Ref: TestSublistHasInterestOverlapping (sublist_test.go:1775)
///
[Fact]
public void HasInterest_overlapping_subscriptions()
{
var sl = new SubList();
sl.Insert(MakeSub("stream.A.child", sid: "1"));
sl.Insert(MakeSub("stream.*", sid: "2"));
sl.HasInterest("stream.A.child").ShouldBeTrue();
sl.HasInterest("stream.A").ShouldBeTrue();
}
// =========================================================================
// NumInterest
// =========================================================================
///
/// NumInterest returns counts of plain and queue subscribers separately for
/// literal subjects, wildcards, and queue-group subjects.
/// Ref: TestSublistNumInterest (sublist_test.go:1783)
///
[Fact]
public void NumInterest_with_plain_subs()
{
var sl = new SubList();
var fooSub = MakeSub("foo", sid: "1");
sl.Insert(fooSub);
var (np, nq) = sl.NumInterest("foo");
np.ShouldBe(1);
nq.ShouldBe(0);
sl.NumInterest("bar").ShouldBe((0, 0));
sl.Remove(fooSub);
sl.NumInterest("foo").ShouldBe((0, 0));
}
[Fact]
public void NumInterest_with_wildcards()
{
var sl = new SubList();
var sub = MakeSub("foo.*", sid: "1");
sl.Insert(sub);
sl.NumInterest("foo").ShouldBe((0, 0));
sl.NumInterest("foo.bar").ShouldBe((1, 0));
sl.NumInterest("foo.bar.baz").ShouldBe((0, 0));
sl.Remove(sub);
sl.NumInterest("foo.bar").ShouldBe((0, 0));
}
[Fact]
public void NumInterest_with_queue_subs()
{
var sl = new SubList();
var qsub = MakeSub("foo", queue: "bar", sid: "1");
var qsub2 = MakeSub("foo", queue: "baz", sid: "2");
var qsub3 = MakeSub("foo", queue: "baz", sid: "3");
sl.Insert(qsub);
sl.NumInterest("foo").ShouldBe((0, 1));
sl.Insert(qsub2);
sl.NumInterest("foo").ShouldBe((0, 2));
sl.Insert(qsub3);
sl.NumInterest("foo").ShouldBe((0, 3));
sl.Remove(qsub);
sl.NumInterest("foo").ShouldBe((0, 2));
sl.Remove(qsub2);
sl.NumInterest("foo").ShouldBe((0, 1));
sl.Remove(qsub3);
sl.NumInterest("foo").ShouldBe((0, 0));
}
// =========================================================================
// Reverse match
// =========================================================================
///
/// ReverseMatch finds registered patterns that would match a given literal or
/// wildcard subject, covering all combinations of *, >, and literals.
/// Ref: TestSublistReverseMatch (sublist_test.go:1440)
///
[Fact]
public void ReverseMatch_comprehensive()
{
var sl = new SubList();
var fooSub = MakeSub("foo", sid: "1");
var barSub = MakeSub("bar", sid: "2");
var fooBarSub = MakeSub("foo.bar", sid: "3");
var fooBazSub = MakeSub("foo.baz", sid: "4");
var fooBarBazSub = MakeSub("foo.bar.baz", sid: "5");
sl.Insert(fooSub);
sl.Insert(barSub);
sl.Insert(fooBarSub);
sl.Insert(fooBazSub);
sl.Insert(fooBarBazSub);
// ReverseMatch("foo") — only fooSub
var r = sl.ReverseMatch("foo");
r.PlainSubs.Length.ShouldBe(1);
r.PlainSubs.ShouldContain(fooSub);
// ReverseMatch("bar") — only barSub
r = sl.ReverseMatch("bar");
r.PlainSubs.ShouldHaveSingleItem();
r.PlainSubs.ShouldContain(barSub);
// ReverseMatch("*") — single-token subs: foo and bar
r = sl.ReverseMatch("*");
r.PlainSubs.Length.ShouldBe(2);
r.PlainSubs.ShouldContain(fooSub);
r.PlainSubs.ShouldContain(barSub);
// ReverseMatch("baz") — no match
sl.ReverseMatch("baz").PlainSubs.ShouldBeEmpty();
// ReverseMatch("foo.*") — foo.bar and foo.baz
r = sl.ReverseMatch("foo.*");
r.PlainSubs.Length.ShouldBe(2);
r.PlainSubs.ShouldContain(fooBarSub);
r.PlainSubs.ShouldContain(fooBazSub);
// ReverseMatch("*.*") — same two
r = sl.ReverseMatch("*.*");
r.PlainSubs.Length.ShouldBe(2);
r.PlainSubs.ShouldContain(fooBarSub);
r.PlainSubs.ShouldContain(fooBazSub);
// ReverseMatch("*.bar") — only fooBarSub
r = sl.ReverseMatch("*.bar");
r.PlainSubs.ShouldHaveSingleItem();
r.PlainSubs.ShouldContain(fooBarSub);
// ReverseMatch("bar.*") — no match
sl.ReverseMatch("bar.*").PlainSubs.ShouldBeEmpty();
// ReverseMatch("foo.>") — 3 subs under foo
r = sl.ReverseMatch("foo.>");
r.PlainSubs.Length.ShouldBe(3);
r.PlainSubs.ShouldContain(fooBarSub);
r.PlainSubs.ShouldContain(fooBazSub);
r.PlainSubs.ShouldContain(fooBarBazSub);
// ReverseMatch(">") — all 5 subs
r = sl.ReverseMatch(">");
r.PlainSubs.Length.ShouldBe(5);
}
///
/// ReverseMatch finds a subscription even when the query has extra wildcard
/// tokens beyond what the stored pattern has.
/// Ref: TestSublistReverseMatchWider (sublist_test.go:1508)
///
[Fact]
public void ReverseMatch_wider_query()
{
var sl = new SubList();
var sub = MakeSub("uplink.*.*.>");
sl.Insert(sub);
sl.ReverseMatch("uplink.1.*.*.>").PlainSubs.ShouldHaveSingleItem();
sl.ReverseMatch("uplink.1.2.3.>").PlainSubs.ShouldHaveSingleItem();
}
// =========================================================================
// Match with empty tokens (should yield no results)
// =========================================================================
///
/// Subjects with empty tokens (leading/trailing/double dots) never match any
/// subscription, even when a catch-all '>' subscription is present.
/// Ref: TestSublistMatchWithEmptyTokens (sublist_test.go:1522)
///
[Theory]
[InlineData(".foo")]
[InlineData("..foo")]
[InlineData("foo..")]
[InlineData("foo.")]
[InlineData("foo..bar")]
[InlineData("foo...bar")]
public void Match_with_empty_tokens_returns_empty(string badSubject)
{
// Ref: TestSublistMatchWithEmptyTokens (sublist_test.go:1522)
var sl = new SubList();
sl.Insert(MakeSub(">", sid: "1"));
sl.Insert(MakeSub(">", queue: "queue", sid: "2"));
var r = sl.Match(badSubject);
r.PlainSubs.ShouldBeEmpty();
r.QueueSubs.ShouldBeEmpty();
}
// =========================================================================
// Interest notification (adapted from Go's channel-based API to .NET events)
// =========================================================================
///
/// The InterestChanged event fires when subscriptions are inserted or removed.
/// Inserting the first subscriber fires LocalAdded; removing the last fires
/// LocalRemoved. Adding a second subscriber does not fire a redundant event.
/// Ref: TestSublistRegisterInterestNotification (sublist_test.go:1126) —
/// the Go API uses RegisterNotification with a channel; the .NET port exposes
/// an event instead.
///
[Fact]
public void InterestChanged_fires_on_first_insert_and_last_remove()
{
using var sl = new SubList();
var events = new List();
sl.InterestChanged += e => events.Add(e);
var sub1 = MakeSub("foo", sid: "1");
var sub2 = MakeSub("foo", sid: "2");
sl.Insert(sub1);
events.Count.ShouldBe(1);
events[0].Kind.ShouldBe(InterestChangeKind.LocalAdded);
events[0].Subject.ShouldBe("foo");
sl.Insert(sub2);
events.Count.ShouldBe(2); // second insert still fires (event per operation)
sl.Remove(sub1);
events.Count.ShouldBe(3);
events[2].Kind.ShouldBe(InterestChangeKind.LocalRemoved);
sl.Remove(sub2);
events.Count.ShouldBe(4);
events[3].Kind.ShouldBe(InterestChangeKind.LocalRemoved);
}
///
/// InterestChanged events are raised for queue subscriptions with the correct
/// Queue field populated.
/// Ref: TestSublistRegisterInterestNotification (queue sub section) (sublist_test.go:1321)
///
[Fact]
public void InterestChanged_carries_queue_name_for_queue_subs()
{
using var sl = new SubList();
var events = new List();
sl.InterestChanged += e => events.Add(e);
var qsub = MakeSub("foo.bar.baz", queue: "q1", sid: "1");
sl.Insert(qsub);
events[0].Queue.ShouldBe("q1");
events[0].Subject.ShouldBe("foo.bar.baz");
events[0].Kind.ShouldBe(InterestChangeKind.LocalAdded);
sl.Remove(qsub);
events[1].Kind.ShouldBe(InterestChangeKind.LocalRemoved);
events[1].Queue.ShouldBe("q1");
}
///
/// RemoveBatch removes all specified subscriptions in a single operation.
/// Unlike individual Remove calls, RemoveBatch performs the removal atomically
/// under a single write lock and does not fire InterestChanged per element —
/// it is optimised for bulk teardown (e.g. client disconnect).
/// After the batch, Match confirms that all removed subjects are gone.
/// Ref: TestSublistRegisterInterestNotification (batch insert/remove) (sublist_test.go:1311)
///
[Fact]
public void RemoveBatch_removes_all_and_subscription_count_drops_to_zero()
{
using var sl = new SubList();
var inserts = new List();
sl.InterestChanged += e =>
{
if (e.Kind == InterestChangeKind.LocalAdded) inserts.Add(e);
};
var subs = Enumerable.Range(1, 4)
.Select(i => MakeSub("foo", sid: i.ToString()))
.ToArray();
foreach (var s in subs) sl.Insert(s);
inserts.Count.ShouldBe(4);
sl.Count.ShouldBe(4u);
// RemoveBatch atomically removes all — count goes to zero
sl.RemoveBatch(subs);
sl.Count.ShouldBe(0u);
sl.Match("foo").PlainSubs.ShouldBeEmpty();
}
}