Skip to content

Commit ad250c9

Browse files
HandyS11claude
andcommitted
fix(2a): strengthen marker-poll baseline test + deterministic fake script
- FakeRustSocketSource: add source-level EnqueueMarkers that pre-stages marker lists and transfers them to FakeConnection at Create time, eliminating the setup race between test code and the poll loop. - FakeConnection: add per-connection ConcurrentQueue<IReadOnlyList< MapMarkerSnapshot>> with "hold last" behaviour mirroring NextHeartbeat; EnqueueMarkers on the connection available for post-connect injection. Existing MarkersResult fallback preserved for Task-2 / no-script callers. - First_marker_poll_is_a_silent_baseline: rewritten to script poll 1+2 with a CargoShip (baseline + no-change) and poll 3 with CargoShip+Heli; waits for the definite heli-added event as the race-free signal proving both baseline suppression and diff correctness. - Marker_added_on_a_later_poll_publishes_changed_event: scripted via source queue before EnsureConnectionAsync; asserts exact MapDimensions(4000,4000, 500) from the FakeConnection default. - Failed_marker_poll_retains_previous_snapshot: polls 1+2 scripted via source queue; MarkersThrow used post-event for the throw/recover cycle; hold-last means recovery sees the same CargoShip → no spurious event. - ConnectionOptions.MarkerPollInterval: init → set for IOptions binding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ff052e0 commit ad250c9

3 files changed

Lines changed: 115 additions & 58 deletions

File tree

src/RustPlusBot.Features.Connections/ConnectionOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,5 @@ public sealed class ConnectionOptions
1919
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(10);
2020

2121
/// <summary>How often to poll map markers for live-event detection. Default 10s.</summary>
22-
public TimeSpan MarkerPollInterval { get; init; } = TimeSpan.FromSeconds(10);
22+
public TimeSpan MarkerPollInterval { get; set; } = TimeSpan.FromSeconds(10);
2323
}

tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs

Lines changed: 59 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,16 @@ public async Task StartAll_StartsAConnectionPerConnectableServer()
250250
[Fact]
251251
public async Task First_marker_poll_is_a_silent_baseline()
252252
{
253-
// Arrange: the connection always returns an empty marker list.
254-
// After two full poll cycles the first (silent baseline) plus one repeated poll should
255-
// produce no MapMarkersChangedEvent because there is no diff to report.
253+
// Contract: the FIRST poll after connect must not publish any event even when markers are
254+
// present on that first poll (the baseline records them silently). Only LATER changes alert.
255+
//
256+
// Script:
257+
// Poll 1 → [CargoShip] (baseline — must NOT fire an event)
258+
// Poll 2 → [CargoShip] (no change — still no event)
259+
// Poll 3 → [CargoShip, PatrolHelicopter] (heli added — ONE event, Added=[heli])
260+
//
261+
// Waiting for the definite heli-event signal proves both that baseline suppression held AND
262+
// that the diff works, without any fixed-sleep assertion.
256263
var source = new FakeRustSocketSource();
257264
source.EnqueueConnect(SocketConnectOutcome.Connected);
258265
source.EnqueueHeartbeat(HeartbeatResult.Ok(1));
@@ -269,16 +276,25 @@ public async Task First_marker_poll_is_a_silent_baseline()
269276
}
270277
}, CancellationToken.None);
271278

272-
await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
279+
// Script the three polls before EnsureConnectionAsync so the script is in place before the
280+
// poll loop starts — no race between test setup and the supervisor's background poll task.
281+
var cargo = new MapMarkerSnapshot(1UL, MarkerKind.CargoShip, 1f, 1f, null);
282+
var heli = new MapMarkerSnapshot(2UL, MarkerKind.PatrolHelicopter, 2f, 2f, null);
283+
source.EnqueueMarkers([cargo]); // poll 1: baseline (silent)
284+
source.EnqueueMarkers([cargo]); // poll 2: no change
285+
source.EnqueueMarkers([cargo, heli]); // poll 3: heli added → one event
273286

274-
// FakeConnection defaults to empty MarkersResult — baseline poll sees nothing.
275-
await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token);
287+
await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
276288

277-
// Wait two full poll cycles so the baseline and one follow-up are definitely consumed.
278-
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
289+
// Wait for the heli-added event — its arrival is the definite signal that at least three
290+
// poll cycles have completed and proves the baseline CargoShip never triggered an event.
291+
await WaitUntilAsync(() => !captured.IsEmpty, cts.Token);
279292

280-
// The first poll establishes a silent baseline; repeated empty polls produce no event.
281-
Assert.Empty(captured);
293+
Assert.Single(captured);
294+
Assert.True(captured.TryPeek(out var evt));
295+
Assert.Single(evt!.Added);
296+
Assert.Equal(MarkerKind.PatrolHelicopter, evt.Added[0].Kind);
297+
Assert.Empty(evt.Removed);
282298

283299
await h.Supervisor.StopAllAsync();
284300
await cts.CancelAsync();
@@ -292,7 +308,12 @@ public async Task First_marker_poll_is_a_silent_baseline()
292308
[Fact]
293309
public async Task Marker_added_on_a_later_poll_publishes_changed_event()
294310
{
295-
// Arrange: first poll sees nothing (baseline); second poll sees a CargoShip → one event.
311+
// Contract: first poll is a silent baseline; a new marker on a subsequent poll fires exactly
312+
// one MapMarkersChangedEvent with the correct Added entry and the connect-time dimensions.
313+
//
314+
// Script:
315+
// Poll 1 → [] (baseline — no event)
316+
// Poll 2 → [CargoShip] (added → one event)
296317
var source = new FakeRustSocketSource();
297318
source.EnqueueConnect(SocketConnectOutcome.Connected);
298319
source.EnqueueHeartbeat(HeartbeatResult.Ok(1));
@@ -309,33 +330,26 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event()
309330
}
310331
}, CancellationToken.None);
311332

312-
await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
333+
// FakeConnection default DimensionsResult is new(4000u, 4000u, 500); assert those exact values.
334+
var expectedDims = new MapDimensions(4000u, 4000u, 500);
313335

314-
// Wait for the fake connection to exist; start with empty markers (baseline poll sees nothing).
315-
await WaitUntilAsync(() => source.LastConnection is not null, cts.Token);
316-
source.LastConnection!.MarkersResult = [];
317-
await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token);
336+
// Script polls before EnsureConnectionAsync so the marker script is in the connection before
337+
// the poll loop can start — eliminates any setup race.
338+
source.EnqueueMarkers([]); // poll 1: baseline
339+
source.EnqueueMarkers([new MapMarkerSnapshot(2UL, MarkerKind.CargoShip, 1f, 1f, "Cargo A")]); // poll 2
318340

319-
// Wait one poll cycle for the baseline to be established, then add a CargoShip.
320-
await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token);
321-
source.LastConnection!.MarkersResult =
322-
[new MapMarkerSnapshot(2UL, MarkerKind.CargoShip, 1f, 1f, "Cargo A")];
341+
await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
323342

324-
// Wait until the event arrives (up to 30s deadline).
325-
var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
326-
while (captured.IsEmpty && DateTimeOffset.UtcNow < deadline)
327-
{
328-
await Task.Delay(15, cts.Token);
329-
}
343+
// Wait for the definite signal: the CargoShip-added event.
344+
await WaitUntilAsync(() => !captured.IsEmpty, cts.Token);
330345

331346
Assert.Single(captured);
332-
var evt = captured.TryPeek(out var e) ? e : null;
347+
Assert.True(captured.TryPeek(out var evt));
333348
Assert.NotNull(evt);
334349
Assert.Single(evt!.Added);
335350
Assert.Equal(MarkerKind.CargoShip, evt.Added[0].Kind);
336351
Assert.Empty(evt.Removed);
337-
// Dimensions should ride along from the connect-time fetch.
338-
Assert.NotNull(evt.Dimensions);
352+
Assert.Equal(expectedDims, evt.Dimensions);
339353

340354
await h.Supervisor.StopAllAsync();
341355
await cts.CancelAsync();
@@ -349,8 +363,13 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event()
349363
[Fact]
350364
public async Task Failed_marker_poll_retains_previous_snapshot()
351365
{
352-
// Arrange: baseline is empty. The second poll adds a CargoShip (event published).
353-
// The third poll throws (no event). The fourth returns the same CargoShip (no spurious diff).
366+
// Contract: a thrown poll does not corrupt the previous snapshot.
367+
//
368+
// Script:
369+
// Poll 1 → [] (baseline — no event)
370+
// Poll 2 → [CargoShip] (added → exactly one event; queue empties, hold-last = CargoShip)
371+
// Poll 3 → throws (MarkersThrow = true; no event, snapshot retained)
372+
// Poll 4 → [CargoShip] (same as held-last → no spurious diff, still exactly one event total)
354373
var source = new FakeRustSocketSource();
355374
source.EnqueueConnect(SocketConnectOutcome.Connected);
356375
source.EnqueueHeartbeat(HeartbeatResult.Ok(1));
@@ -367,35 +386,24 @@ public async Task Failed_marker_poll_retains_previous_snapshot()
367386
}
368387
}, CancellationToken.None);
369388

370-
await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
371-
372-
await WaitUntilAsync(() => source.LastConnection is not null, cts.Token);
373-
source.LastConnection!.MarkersResult = [];
374-
await WaitUntilAsync(() => h.Supervisor.HasLiveSocket(10UL, serverId), cts.Token);
375-
376-
// Let baseline settle.
377-
await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token);
378-
379-
// Poll 2: add CargoShip → expect exactly one event.
380389
var cargo = new MapMarkerSnapshot(3UL, MarkerKind.CargoShip, 2f, 2f, null);
381-
source.LastConnection!.MarkersResult = [cargo];
390+
// Script polls before EnsureConnectionAsync to eliminate the setup race.
391+
source.EnqueueMarkers([]); // poll 1: baseline
392+
source.EnqueueMarkers([cargo]); // poll 2: CargoShip added; queue empties → hold-last = [cargo]
382393

383-
var deadline = DateTimeOffset.UtcNow.AddSeconds(30);
384-
while (captured.IsEmpty && DateTimeOffset.UtcNow < deadline)
385-
{
386-
await Task.Delay(15, cts.Token);
387-
}
394+
await h.Supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token);
388395

396+
// Wait for the one cargo-added event — definite signal that poll 2 completed.
397+
await WaitUntilAsync(() => !captured.IsEmpty, cts.Token);
389398
Assert.Single(captured);
390399

391-
// Poll 3: make the poll throw.
400+
// Poll 3: make the next poll throw; the poll loop catches the exception and retains the snapshot.
392401
source.LastConnection!.MarkersThrow = true;
393-
// Give it time to attempt the failing poll.
402+
// Give enough time for at least one failing poll to be attempted.
394403
await Task.Delay(TimeSpan.FromMilliseconds(60), cts.Token);
395404

396-
// Poll 4: recover; same CargoShip → snapshot unchanged, no new event.
405+
// Poll 4: recover — hold-last still returns [cargo], so snapshot is unchanged, no new event.
397406
source.LastConnection!.MarkersThrow = false;
398-
source.LastConnection!.MarkersResult = [cargo];
399407
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
400408

401409
// Still only one event total — the failed poll did not corrupt the snapshot.

tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource
1919
{
2020
private readonly ConcurrentQueue<SocketConnectOutcome> _connectOutcomes = new();
2121
private readonly ConcurrentQueue<HeartbeatResult> _heartbeats = new();
22+
private readonly ConcurrentQueue<IReadOnlyList<MapMarkerSnapshot>> _pendingMarkerScript = new();
2223
private int _createCount;
2324

2425
private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
@@ -42,6 +43,12 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
4243
LastSteamId = steamId;
4344
var outcome = _connectOutcomes.TryDequeue(out var next) ? next : SocketConnectOutcome.Connected;
4445
var connection = new FakeConnection(outcome, this);
46+
// Transfer any pre-staged marker script so it is in place before the poll loop starts.
47+
while (_pendingMarkerScript.TryDequeue(out var markers))
48+
{
49+
connection.EnqueueMarkers(markers);
50+
}
51+
4552
LastConnection = connection;
4653
return connection;
4754
}
@@ -50,6 +57,16 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
5057

5158
public void EnqueueHeartbeat(HeartbeatResult result) => _heartbeats.Enqueue(result);
5259

60+
/// <summary>
61+
/// Pre-stages a scripted marker list for the NEXT connection created by <see cref="Create"/>.
62+
/// All items enqueued here are transferred to the new <see cref="FakeConnection"/> at creation
63+
/// time, before the supervisor can start the poll loop, eliminating the setup race. Call this
64+
/// before <see cref="EnsureConnectionAsync"/> so the script is in place when polls begin.
65+
/// </summary>
66+
/// <param name="markers">The marker list to deliver on the corresponding poll.</param>
67+
public void EnqueueMarkers(IReadOnlyList<MapMarkerSnapshot> markers) =>
68+
_pendingMarkerScript.Enqueue(markers);
69+
5370
internal HeartbeatResult NextHeartbeat()
5471
{
5572
if (_heartbeats.TryDequeue(out var next))
@@ -63,6 +80,10 @@ internal HeartbeatResult NextHeartbeat()
6380
internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocketSource source)
6481
: IRustServerConnection
6582
{
83+
private readonly ConcurrentQueue<IReadOnlyList<MapMarkerSnapshot>> _markerScript = new();
84+
private IReadOnlyList<MapMarkerSnapshot> _lastMarkers = [];
85+
private bool _markerScriptStarted;
86+
6687
/// <summary>Gets the messages sent via <see cref="SendTeamMessageAsync"/>.</summary>
6788
public List<string> SentMessages { get; } = [];
6889

@@ -81,10 +102,14 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
81102
/// <summary>The Steam ID passed to the most recent <see cref="PromoteToLeaderAsync"/> call.</summary>
82103
public ulong LastPromotedSteamId { get; private set; }
83104

84-
/// <summary>The markers returned by <see cref="GetMapMarkersAsync"/>. Defaults to empty (nothing on the map).</summary>
105+
/// <summary>
106+
/// The fallback markers returned by <see cref="GetMapMarkersAsync"/> when no scripted results remain.
107+
/// Defaults to empty (nothing on the map). Callers that do not use <see cref="EnqueueMarkers"/> see
108+
/// this value on every poll, matching the original Task-2 behavior.
109+
/// </summary>
85110
public IReadOnlyList<MapMarkerSnapshot> MarkersResult { get; set; } = [];
86111

87-
/// <summary>When true, <see cref="GetMapMarkersAsync"/> throws (simulates a failed poll).</summary>
112+
/// <summary>When true, <see cref="GetMapMarkersAsync"/> throws regardless of any enqueued script.</summary>
88113
public bool MarkersThrow { get; set; }
89114

90115
/// <summary>The dimensions returned by <see cref="GetMapDimensionsAsync"/>. Defaults to a non-null snapshot.</summary>
@@ -123,17 +148,41 @@ public Task<bool> PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
123148
}
124149

125150
public Task<IReadOnlyList<MapMarkerSnapshot>> GetMapMarkersAsync(TimeSpan timeout,
126-
CancellationToken cancellationToken = default) =>
127-
MarkersThrow
128-
? Task.FromException<IReadOnlyList<MapMarkerSnapshot>>(new InvalidOperationException("poll failed"))
129-
: Task.FromResult(MarkersResult);
151+
CancellationToken cancellationToken = default)
152+
{
153+
if (MarkersThrow)
154+
{
155+
return Task.FromException<IReadOnlyList<MapMarkerSnapshot>>(
156+
new InvalidOperationException("poll failed"));
157+
}
158+
159+
if (_markerScript.TryDequeue(out var scripted))
160+
{
161+
_markerScriptStarted = true;
162+
_lastMarkers = scripted;
163+
return Task.FromResult(_lastMarkers);
164+
}
165+
166+
// Once any scripted result has been dequeued, hold the last one (mirroring NextHeartbeat).
167+
// If the script was never started, fall back to MarkersResult so Task-2 callers are unaffected.
168+
return Task.FromResult(_markerScriptStarted ? _lastMarkers : MarkersResult);
169+
}
130170

131171
public Task<MapDimensions?> GetMapDimensionsAsync(TimeSpan timeout,
132172
CancellationToken cancellationToken = default) =>
133173
Task.FromResult(DimensionsResult);
134174

135175
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
136176

177+
/// <summary>
178+
/// Enqueues a scripted marker list to be returned by the next <see cref="GetMapMarkersAsync"/> call.
179+
/// Once the queue empties the last dequeued list is held and returned on every subsequent poll,
180+
/// mirroring the heartbeat "hold last" pattern. Enqueued results take priority over
181+
/// <see cref="MarkersResult"/>; if nothing has been enqueued, <see cref="MarkersResult"/> is used.
182+
/// </summary>
183+
/// <param name="markers">The marker list to return for the next poll.</param>
184+
public void EnqueueMarkers(IReadOnlyList<MapMarkerSnapshot> markers) => _markerScript.Enqueue(markers);
185+
137186
/// <summary>Raises <see cref="TeamMessageReceived"/> to simulate an inbound team chat line.</summary>
138187
/// <param name="line">The team chat line to raise.</param>
139188
public void RaiseTeamMessage(TeamChatLine line) => TeamMessageReceived?.Invoke(this, line);

0 commit comments

Comments
 (0)