Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
a651f81
feat(send-receive): make the write gate reject per-project + emit a b…
rolfheij-sil Jul 16, 2026
d8e1271
feat(send-receive): add SendReceiveBlockNotifierService bridging the …
rolfheij-sil Jul 16, 2026
0f7c516
refactor(renderer): make auto-sync-blocking store a per-project snaps…
rolfheij-sil Jul 16, 2026
32b081a
feat(renderer): drive auto-sync blocking from the backend write-gate …
rolfheij-sil Jul 16, 2026
19bd137
feat(renderer): edit-block only the syncing projects' editors (PT-421…
rolfheij-sil Jul 16, 2026
08c370f
fix(send-receive): review fixes — truthful armed-empty-set warning, p…
rolfheij-sil Jul 16, 2026
d8542b4
refactor(renderer): drop dead boolean blocking getter, pin swap-trans…
rolfheij-sil Jul 16, 2026
2ca99a3
feat(c-sharp): register onSyncWriteLockChanged with the central event…
rolfheij-sil Jul 17, 2026
952a090
fix(renderer): reconcile #2571's review fixes with the snapshot model
rolfheij-sil Jul 17, 2026
61fcd63
test(send-receive): cover notifier registration-failure branches
rolfheij-sil Jul 17, 2026
6fc2197
fix(auto-sync): address lyonsil's #2574 review round (PT-4214 Stage U)
timothy-mccormack Jul 21, 2026
2747fe7
docs(platform-scripture-editor): mark onWillSwitchProject/onDidSwitch…
timothy-mccormack Jul 21, 2026
b533d95
refactor(platform-scripture-editor): register switch-project events v…
timothy-mccormack Jul 21, 2026
cfa3390
docs: reformulate backward-facing PT-4214 comments as forward-facing …
timothy-mccormack Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions c-sharp-tests/DummyPapiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ internal class DummyPapiClient : PapiClient
private readonly ConcurrentQueue<(string eventType, object? eventParameters)> _sentEvents =
new();

private readonly Queue<(
string requestType,
IReadOnlyList<object?>? requestContents
)> _sentRequests = [];

// ConcurrentDictionary (not Dictionary): RegisterRequestHandlerAsync writes this on every
// PDP registration, and per-project PDP creation registers PDPs concurrently (fire-
// and-forget, not serialized behind a single creation lock - see
Expand All @@ -25,6 +30,16 @@ private readonly ConcurrentDictionary<
OpenRpcSingleMethodDocumentation?
> _documentationByRequestType = new();

/// <summary>
/// Test-only override for how a <c>network:registerEvent</c> request resolves. Defaults to
/// "main accepted" (returns <c>true</c>) — the normal happy path that services under test
/// rely on so they don't take their registration-failure branches. A test can swap in a
/// delegate that returns <c>false</c> (registry rejection) or throws (registry failure) to
/// exercise those best-effort branches. Scoped strictly to <c>network:registerEvent</c> so
/// no other test's <see cref="SendRequestAsync{T}"/> expectations change.
/// </summary>
public Func<bool> RegisterEventResponse { get; set; } = () => true;

#region Overrides of PapiClient

public override Task<bool> ConnectAsync()
Expand Down Expand Up @@ -106,9 +121,37 @@ public int SentEventCount
{
if (_localMethods.TryGetValue(requestType, out _))
return base.SendRequestAsync<T>(requestType, requestContents);
_sentRequests.Enqueue((requestType, requestContents));
// Central-registry event registration returns an acceptance boolean; defer to the
// configurable RegisterEventResponse (defaults to "main accepted", mirroring
// ConnectAsync's "pretend we succeeded") so a test can drive the registration-failure
// paths. Kept strictly to this one request type so no other test's SendRequestAsync
// expectations change.
if (requestType == "network:registerEvent")
// T is bool here; there is no non-casting way to satisfy the generic return type.
return Task.FromResult<T?>((T)(object)RegisterEventResponse());
return Task.FromResult<T?>(default);
}

/// <summary>
/// Test-only count of requests sent through <see cref="SendRequestAsync{T}"/> that were NOT
/// handled by a locally-registered handler (i.e. would have gone over the wire to main).
/// </summary>
public int SentRequestCount
{
get { return _sentRequests.Count; }
}

/// <summary>
/// Test-only dequeue of the oldest captured outgoing request (see
/// <see cref="SentRequestCount"/>). Lets tests assert wire calls like
/// <c>network:registerEvent</c> without a live PAPI connection.
/// </summary>
public (string requestType, IReadOnlyList<object?>? requestContents) NextSentRequest
{
get { return _sentRequests.Dequeue(); }
}

/// <summary>
/// Test-only accessor that reports whether a handler is registered in
/// <c>_localMethods</c> for the given wire name. Exposes the protected
Expand All @@ -119,6 +162,20 @@ public int SentEventCount
public bool IsHandlerRegistered(string requestType) =>
_localMethods.ContainsKey(requestType);

/// <summary>
/// Test-only helper that invokes a locally-registered request handler directly (bypassing the
/// websocket the base client would use) and returns its result. Lets a test assert what a
/// registered command handler returns without a live PAPI connection.
/// </summary>
public object? InvokeRequestHandler(string requestType, params object?[] args)
{
if (!_localMethods.TryGetValue(requestType, out var handler))
throw new InvalidOperationException(
$"No handler registered for request type \"{requestType}\""
);
return handler.DynamicInvoke(args);
}

#endregion
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Paranext.DataProvider.JsonUtils;
using Paranext.DataProvider.Projects.SendReceive;

namespace TestParanextDataProvider.Projects.SendReceive
{
/// <summary>
/// Unit tests for <see cref="SendReceiveBlockNotifierService"/>: it forwards
/// <see cref="SendReceiveWriteLock"/> transitions to the PAPI as the
/// <c>paratextBibleSendReceive.onSyncWriteLockChanged</c> event and answers the
/// <c>command:paratextBibleSendReceive.getAutoSyncBlocking</c> pull command with the current
/// snapshot. Uses <see cref="DummyPapiClient"/> (the base fixture's <c>Client</c>), which captures
/// sent events and locally-registered handlers, so no live PAPI connection is needed. Every test
/// resets the static gate before and after so a subscription can never leak between tests.
/// </summary>
[TestFixture]
[ExcludeFromCodeCoverage]
internal class SendReceiveBlockNotifierServiceTests : PapiTestBase
{
private const string BlockStateChangedEvent =
"paratextBibleSendReceive.onSyncWriteLockChanged";
private const string GetAutoSyncBlockingCommand =
"command:paratextBibleSendReceive.getAutoSyncBlocking";

private SendReceiveBlockNotifierService _service = null!;

[SetUp]
public override async Task TestSetupAsync()
{
await base.TestSetupAsync();
SendReceiveWriteLock.ResetForTests();
_service = new SendReceiveBlockNotifierService(Client);
await _service.InitializeAsync();
}

[TearDown]
public void ResetGate() => SendReceiveWriteLock.ResetForTests();

[Test]
public void InitializeAsync_RegistersTheGetAutoSyncBlockingCommand()
{
Assert.That(
Client.IsHandlerRegistered(GetAutoSyncBlockingCommand),
Is.True,
"InitializeAsync must register the getAutoSyncBlocking command handler"
);
}

[Test]
public void InitializeAsync_RegistersTheEventWithTheCentralRegistry()
{
Assert.That(
Client.SentRequestCount,
Is.EqualTo(1),
"InitializeAsync must send exactly one wire request: the event registration"
);
var (requestType, requestContents) = Client.NextSentRequest;
Assert.Multiple(() =>
{
Assert.That(requestType, Is.EqualTo("network:registerEvent"));
Assert.That(
requestContents,
Is.EqualTo(new object?[] { BlockStateChangedEvent }),
"the registration must name the onSyncWriteLockChanged event"
);
});
}

[Test]
public async Task InitializeAsync_RegistryRejectsEventRegistration_LogsAndStillEmits()
{
// Best-effort contract: if main's central registry rejects the event registration
// (returns false), InitializeAsync must warn and continue — the service still registers
// its command handler and still pushes gate transitions. Build a fresh gate + client so
// only the service under test here is subscribed (the base SetUp already initialized on
// the happy path).
SendReceiveWriteLock.ResetForTests();
using var client = new DummyPapiClient { RegisterEventResponse = () => false };
var service = new SendReceiveBlockNotifierService(client);

using var consoleError = new StringWriter();
var originalError = Console.Error;
Console.SetError(consoleError);
try
{
await service.InitializeAsync();
}
finally
{
Console.SetError(originalError);
}

Assert.Multiple(() =>
{
Assert.That(
consoleError.ToString(),
Does.Contain(BlockStateChangedEvent),
"a rejected registration must be logged, naming the event"
);
Assert.That(
client.IsHandlerRegistered(GetAutoSyncBlockingCommand),
Is.True,
"a rejected registration must not stop the command handler from registering"
);
});

// The service still forwards a gate transition despite the rejected registration.
SendReceiveWriteLock.SetSyncing(["projectA"]);
Assert.That(
client.SentEventCount,
Is.EqualTo(1),
"the service must still push the block-state event after a rejected registration"
);
var (eventType, _) = client.NextSentEvent;
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
}

[Test]
public async Task InitializeAsync_EventRegistrationThrows_IsCaughtAndStillEmits()
{
// Best-effort contract: if the registration request itself fails (throws), InitializeAsync
// must catch it, log it, and continue — a registry hiccup must never break backend
// startup. Fresh gate + client, as above.
SendReceiveWriteLock.ResetForTests();
using var client = new DummyPapiClient
{
RegisterEventResponse = () =>
throw new InvalidOperationException("registry offline"),
};
var service = new SendReceiveBlockNotifierService(client);

using var consoleError = new StringWriter();
var originalError = Console.Error;
Console.SetError(consoleError);
try
{
// Must not throw: the registration failure is caught internally.
await service.InitializeAsync();
}
finally
{
Console.SetError(originalError);
}

Assert.Multiple(() =>
{
var log = consoleError.ToString();
Assert.That(
log,
Does.Contain(BlockStateChangedEvent),
"a thrown registration must be caught and logged, naming the event"
);
Assert.That(
log,
Does.Contain("registry offline"),
"the caught exception detail must be logged"
);
Assert.That(
client.IsHandlerRegistered(GetAutoSyncBlockingCommand),
Is.True,
"a thrown registration must not stop the command handler from registering"
);
});

// The service still forwards a gate transition despite the thrown registration.
SendReceiveWriteLock.SetSyncing(["projectA"]);
Assert.That(
client.SentEventCount,
Is.EqualTo(1),
"the service must still push the block-state event after a thrown registration"
);
var (eventType, _) = client.NextSentEvent;
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
}

[Test]
public void GateArm_PushesOnSyncWriteLockChangedEventWithBlockingSnapshot()
{
Assert.That(Client.SentEventCount, Is.Zero, "no events before any transition");

SendReceiveWriteLock.SetSyncing(["projectA", "projectB"]);

Assert.That(Client.SentEventCount, Is.EqualTo(1), "an arm must push exactly one event");
var (eventType, payload) = Client.NextSentEvent;
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
Assert.That(payload, Is.InstanceOf<SendReceiveBlockState>());
var state = (SendReceiveBlockState)payload!;
Assert.Multiple(() =>
{
Assert.That(state.IsBlocking, Is.True);
Assert.That(state.ProjectIds, Is.EquivalentTo(new[] { "projectA", "projectB" }));
});
}

[Test]
public void GateClear_PushesOnSyncWriteLockChangedEventWithNotBlockingSnapshot()
{
SendReceiveWriteLock.SetSyncing(["projectA"]);
Assert.That(Client.SentEventCount, Is.EqualTo(1), "the arm event");
_ = Client.NextSentEvent; // discard the arm event

SendReceiveWriteLock.Clear();

Assert.That(Client.SentEventCount, Is.EqualTo(1), "the disarm pushes one more event");
var (eventType, payload) = Client.NextSentEvent;
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
var state = (SendReceiveBlockState)payload!;
Assert.Multiple(() =>
{
Assert.That(state.IsBlocking, Is.False);
Assert.That(state.ProjectIds, Is.Empty);
});
}

[Test]
public void GetAutoSyncBlockingCommand_ReturnsTheCurrentSnapshot()
{
// Not blocking before any sync.
var before = Client.InvokeRequestHandler(GetAutoSyncBlockingCommand);
Assert.That(before, Is.InstanceOf<SendReceiveBlockState>());
Assert.That(((SendReceiveBlockState)before!).IsBlocking, Is.False);

SendReceiveWriteLock.SetSyncing(["projectA"]);

// Blocking, with the armed ids, after arming.
var after = Client.InvokeRequestHandler(GetAutoSyncBlockingCommand);
var state = (SendReceiveBlockState)after!;
Assert.Multiple(() =>
{
Assert.That(state.IsBlocking, Is.True);
Assert.That(state.ProjectIds, Is.EquivalentTo(new[] { "projectA" }));
});
}

[Test]
public void SendReceiveBlockState_SerializesToTheCamelCaseWireShape()
{
// The renderer consumes exactly { isBlocking, projectIds }. Serialize with the same
// options the PAPI JSON-RPC formatter uses (PropertyNamingPolicy = CamelCase) to pin that
// contract at the C# boundary.
var options = SerializationOptions.CreateSerializationOptions();

var json = JsonSerializer.Serialize(
new SendReceiveBlockState(true, new[] { "projectA" }),
options
);

Assert.Multiple(() =>
{
Assert.That(json, Does.Contain("\"isBlocking\":true"));
Assert.That(json, Does.Contain("\"projectIds\":[\"projectA\"]"));
Assert.That(
json,
Does.Not.Contain("IsBlocking"),
"keys must be camelCase on the wire"
);
Assert.That(
json,
Does.Not.Contain("ProjectIds"),
"keys must be camelCase on the wire"
);
});
}
}
}
Loading
Loading