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
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
75 changes: 73 additions & 2 deletions c-sharp-tests/Projects/LocalParatextProjectsNotifyTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
namespace TestParanextDataProvider.Projects
{
/// <summary>
/// Verifies <see cref="LocalParatextProjects.NotifyProjectsChanged"/> debounces: a burst of calls
/// Verifies the notify plumbing of <see cref="LocalParatextProjects"/>:
/// <see cref="LocalParatextProjects.NotifyProjectsChanged"/> debounces — a burst of calls
/// (e.g. an inline setting-write notify plus the watcher catching that same on-disk write, or a
/// run of display-setting writes) must collapse into a single emitted
/// <c>platform.onDidChangeProjects</c> event, since each event drives a full metadata refetch.
/// <c>platform.onDidChangeProjects</c> event, since each event drives a full metadata refetch —
/// and the watcher path funnels through the shared
/// <see cref="LocalParatextProjects.RefreshAndNotifyProjectsChanged"/>, which itself no-ops
/// before <see cref="LocalParatextProjects.Initialize"/> (an early writer must not broadcast
/// a bogus project-list change off an uninitialised <c>ScrTextCollection</c>).
/// </summary>
[ExcludeFromCodeCoverage]
internal class LocalParatextProjectsNotifyTests
Expand All @@ -16,6 +21,43 @@ internal class LocalParatextProjectsNotifyTests
private const int SettleTimeoutMs = 5000;
private const int PollIntervalMs = 50;

// Window to prove an event does NOT arrive: comfortably past the 500ms notify debounce
// (3x), so a wrongly-scheduled emit would land inside it.
private static readonly TimeSpan NoEventSettleWindow = TimeSpan.FromMilliseconds(1500);

/// <summary>
/// Substitutes <see cref="LocalParatextProjects.RefreshAndNotifyProjectsChanged"/> with a
/// counter and exposes the protected watcher callback, so the funnel test below can pin the
/// delegation without touching the global <c>ScrTextCollection</c> (via RefreshScrTexts) or
/// the real PAPI event.
/// </summary>
private sealed class FunnelObservingProjects(DummyPapiClient papiClient)
: LocalParatextProjects(papiClient)
{
public int RefreshAndNotifyCallCount { get; private set; }

public override void RefreshAndNotifyProjectsChanged() => RefreshAndNotifyCallCount++;

public void FireOnProjectDirectoriesChanged() => OnProjectDirectoriesChanged();
}

[Test]
public void OnProjectDirectoriesChanged_DelegatesToRefreshAndNotifyProjectsChanged()
{
// The watcher path must funnel through the shared refresh-then-notify method so its
// best-effort contract (a refresh throw must not suppress the notify) and any test
// substitution of the refresh apply to the inline and watcher paths alike.
using var projects = new FunnelObservingProjects(new DummyPapiClient());

projects.FireOnProjectDirectoriesChanged();

Assert.That(
projects.RefreshAndNotifyCallCount,
Is.EqualTo(1),
"the watcher callback must delegate to RefreshAndNotifyProjectsChanged"
);
}

[Test]
public void NotifyProjectsChanged_CoalescesRapidCallsIntoOneEvent()
{
Expand All @@ -36,5 +78,34 @@ public void NotifyProjectsChanged_CoalescesRapidCallsIntoOneEvent()
Is.EqualTo(LocalParatextProjects.PROJECTS_CHANGED_EVENT_TYPE)
);
}

[Test]
public void RefreshAndNotifyProjectsChanged_BeforeInitialize_DoesNotNotify()
{
// A writer can land before startup initialization completes (e.g. a sync finishing
// before Initialize() has set up the ScrTextCollection). Refreshing then would scan
// an uninitialised collection and broadcast a bogus project-list change, so the
// funnel must no-op until Initialize completes.
var client = new DummyPapiClient();
using var projects = new LocalParatextProjects(client);

projects.RefreshAndNotifyProjectsChanged();

Assert.That(
SpinWait.SpinUntil(() => client.SentEventCount > 0, NoEventSettleWindow),
Is.False,
"RefreshAndNotifyProjectsChanged before Initialize must not emit a project-list change"
);

// Control: the direct notify path has no initialization guard (it never touches the
// ScrTextCollection), so an emit still arrives — proving the zero above pinned the
// guard rather than a client that records nothing.
projects.NotifyProjectsChanged();
Assert.That(
() => client.SentEventCount,
Is.EqualTo(1).After(SettleTimeoutMs, PollIntervalMs),
"NotifyProjectsChanged must still emit pre-Initialize"
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Diagnostics.CodeAnalysis;
using Paranext.DataProvider;
using Paranext.DataProvider.Projects;
using Paranext.DataProvider.Projects.SendReceive;

namespace TestParanextDataProvider.Projects.SendReceive
{
/// <summary>
/// Registration-documentation tests for the open-source
/// <see cref="ParatextProjectSendReceiveService"/> stub: breakSyncLock is @experimental (see
/// the TS declaration), so its registration must carry experimental OpenRPC documentation
/// (<c>x-experimental: true</c>) surfaced by <c>rpc.discover</c> — the same doc-assertion
/// pattern as <c>VersificationConversionServiceTests</c>. Deliberately NOT named
/// <c>ParatextProjectSendReceiveServiceTests</c> (or any of the per-feature
/// <c>ParatextProjectSendReceiveService*Tests</c> names the Paratext 10 Studio overlay adds),
/// so the stub's tests and the overlay's tests can coexist.
/// </summary>
[TestFixture]
[ExcludeFromCodeCoverage]
internal class ParatextProjectSendReceiveServiceStubDocsTests : PapiTestBase
{
[Test]
public async Task InitializeAsync_RegistersBreakSyncLockWithExperimentalDocs()
{
const string wireName = "command:paratextBibleSendReceive.breakSyncLock";
var service = new ParatextProjectSendReceiveService(
Client,
new ParatextProjectDataProviderFactory(Client, ParatextProjects),
new AppInfo("test", "1.0.0", "test"),
ParatextProjects
);

await service.InitializeAsync();

Assert.That(
Client.IsHandlerRegistered(wireName),
Is.True,
"breakSyncLock handler registered under its exact wire name"
);
var docs = Client.GetDocumentationFor(wireName);
Assert.That(docs, Is.Not.Null, "breakSyncLock registered with OpenRPC documentation");
Assert.That(docs!.Method.Experimental, Is.True, "breakSyncLock marked experimental");
Assert.That(
docs.Method.Params.Select(p => p.Name),
Is.EqualTo(new[] { "projectIds" }),
"documents the single projectIds parameter"
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Paranext.DataProvider.JsonUtils;
using Paranext.DataProvider.NetworkObjects.Documentation;
using Paranext.DataProvider.Projects.SendReceive;

namespace TestParanextDataProvider.Projects.SendReceive
Expand Down Expand Up @@ -32,6 +33,10 @@ public override async Task TestSetupAsync()
SendReceiveWriteLock.ResetForTests();
_service = new SendReceiveBlockNotifierService(Client);
await _service.InitializeAsync();
// InitializeAsync emits the current gate snapshot once (the restart re-baseline emit —
// see the dedicated tests below); drain it so each test's event assertions see only the
// transitions that test drives.
_ = Client.NextSentEvent;
}

[TearDown]
Expand Down Expand Up @@ -61,10 +66,39 @@ public void InitializeAsync_RegistersTheEventWithTheCentralRegistry()
Assert.That(requestType, Is.EqualTo("network:registerEvent"));
Assert.That(
requestContents,
Is.EqualTo(new object?[] { BlockStateChangedEvent }),
Has.Count.EqualTo(2),
"the registration must carry the event name and its documentation"
);
Assert.That(
requestContents![0],
Is.EqualTo(BlockStateChangedEvent),
"the registration must name the onSyncWriteLockChanged event"
);
Assert.That(
requestContents[1],
Is.InstanceOf<OpenRpcSingleNotificationDocumentation>(),
"the second argument must be the notification documentation"
);
});
// The event is @experimental (TS declaration), so its registration must carry the
// x-experimental wire marker for rpc.discover.
var documentation = (OpenRpcSingleNotificationDocumentation)requestContents![1]!;
Assert.That(
documentation.Notification.Experimental,
Is.True,
"the event documentation must carry the x-experimental wire marker"
);
}

[Test]
public void InitializeAsync_RegistersGetAutoSyncBlockingWithExperimentalDocs()
{
// The command is @experimental (TS declaration), so its registration must carry
// experimental OpenRPC documentation for rpc.discover (the same doc-assertion pattern as
// VersificationConversionServiceTests).
var docs = Client.GetDocumentationFor(GetAutoSyncBlockingCommand);
Assert.That(docs, Is.Not.Null, "command registered with OpenRPC documentation");
Assert.That(docs!.Method.Experimental, Is.True, "command marked experimental");
}

[Test]
Expand All @@ -90,6 +124,9 @@ public async Task InitializeAsync_RegistryRejectsEventRegistration_LogsAndStillE
{
Console.SetError(originalError);
}
// Drain the init-time current-snapshot emit so the assertions below see only the
// transition push.
_ = client.NextSentEvent;

Assert.Multiple(() =>
{
Expand Down Expand Up @@ -142,6 +179,9 @@ public async Task InitializeAsync_EventRegistrationThrows_IsCaughtAndStillEmits(
{
Console.SetError(originalError);
}
// Drain the init-time current-snapshot emit so the assertions below see only the
// transition push.
_ = client.NextSentEvent;

Assert.Multiple(() =>
{
Expand Down Expand Up @@ -174,10 +214,70 @@ public async Task InitializeAsync_EventRegistrationThrows_IsCaughtAndStillEmits(
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
}

[Test]
public async Task InitializeAsync_EmitsTheCurrentSnapshotOnce()
{
// Restart re-baseline emit: after initialization the service pushes the gate's CURRENT
// snapshot once, so a subscriber that was already listening across a backend restart
// converges on the fresh process's state instead of keeping its stale last-seen state.
// Fresh gate + client so only the service under test here is subscribed (the base SetUp
// already initialized and drained its own init emit).
SendReceiveWriteLock.ResetForTests();
using var client = new DummyPapiClient();
var service = new SendReceiveBlockNotifierService(client);

await service.InitializeAsync();

Assert.That(
client.SentEventCount,
Is.EqualTo(1),
"InitializeAsync must emit the current snapshot exactly once"
);
var (eventType, payload) = client.NextSentEvent;
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
var state = (SendReceiveBlockState)payload!;
Assert.Multiple(() =>
{
Assert.That(
state.IsBlocking,
Is.False,
"an idle gate re-baselines as not blocking"
);
Assert.That(state.ProjectIds, Is.Empty);
});
}

[Test]
public async Task InitializeAsync_GateAlreadyArmed_EmitsTheArmedSnapshot()
{
// The init emit is the gate's CURRENT snapshot, not a hard-coded not-blocking one: a
// service initializing while the gate is already armed must report the armed state.
SendReceiveWriteLock.ResetForTests();
SendReceiveWriteLock.SetSyncing(["projectA"]);
using var client = new DummyPapiClient();
var service = new SendReceiveBlockNotifierService(client);

await service.InitializeAsync();

Assert.That(client.SentEventCount, Is.EqualTo(1));
var (eventType, payload) = client.NextSentEvent;
Assert.That(eventType, Is.EqualTo(BlockStateChangedEvent));
var state = (SendReceiveBlockState)payload!;
Assert.Multiple(() =>
{
Assert.That(state.IsBlocking, Is.True);
Assert.That(state.ProjectIds, Is.EquivalentTo(new[] { "projectA" }));
});
}

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;

namespace Paranext.DataProvider.NetworkObjects.Documentation;

/// <summary>
/// OpenRPC documentation for a single network event (the inner <c>notification</c> object of
/// <see cref="OpenRpcSingleNotificationDocumentation"/>). Same shape as
/// <see cref="OpenRpcMethodDocumentation"/> minus the result — per the OpenRPC convention, a
/// notification has no result. Set <see cref="Experimental"/> to mark the event experimental; it
/// surfaces as <c>x-experimental: true</c> in the live OpenRPC document returned by
/// <c>rpc.discover</c>. Informational only — it does not change runtime behavior.
/// </summary>
public record OpenRpcNotificationDocumentation
{
/// <summary>
/// Marks the notification experimental in the OpenRPC document. Mirrors the TypeScript
/// <c>notification['x-experimental']</c> field. Informational only.
/// </summary>
[JsonPropertyName("x-experimental")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Experimental { get; set; }

/// <summary>A short summary of what the notification carries and when it fires.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Summary { get; set; }

/// <summary>A verbose explanation of the notification behavior.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; set; }

/// <summary>The notification's parameters (its payload), in positional order.</summary>
public IReadOnlyList<OpenRpcContentDescriptor> Params { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Paranext.DataProvider.NetworkObjects.Documentation;

/// <summary>
/// Wire shape sent as the optional second argument to <c>network:registerEvent</c>. Mirrors the
/// TypeScript <c>SingleNotificationDocumentation</c> type (<c>{ notification }</c>) — the
/// notification counterpart of <see cref="OpenRpcSingleMethodDocumentation"/>. The main process
/// stores it and emits it into the OpenRPC document returned by <c>rpc.discover</c>, so
/// C#-registered network events can carry the same documentation (including
/// <c>x-experimental</c>) as TypeScript-registered ones.
/// </summary>
public record OpenRpcSingleNotificationDocumentation
{
/// <summary>The notification documentation.</summary>
public OpenRpcNotificationDocumentation Notification { get; set; } = new();
}
3 changes: 2 additions & 1 deletion c-sharp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ public static async Task Main()
var paratextSendReceiveService = new ParatextProjectSendReceiveService(
papi,
paratextFactory,
appInfo
appInfo,
paratextProjects
);
var inventoryDataProvider = new InventoryDataProvider(papi, paratextProjects);
var checkRunner = new CheckRunner(papi, inventoryDataProvider);
Expand Down
Loading
Loading