From f6d2affdff60a765ae2fb650eea0581fb797a4dc Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Wed, 5 Aug 2026 15:51:57 -0500 Subject: [PATCH 01/18] Add tests for C# sdk and fix enum index compatibility --- .github/workflows/ci.yml | 3 + Cargo.lock | 8 + Cargo.toml | 1 + sdks/csharp/Cargo.toml | 13 + .../csharp/SpacetimeDB.ClientSDK.Godot.csproj | 2 +- sdks/csharp/SpacetimeDB.ClientSDK.csproj | 2 +- sdks/csharp/src/Table.cs | 9 +- sdks/csharp/tests/build-client.sh | 16 + .../connect-disconnect-client/Program.cs | 129 ++++++ .../connect-disconnect-client.csproj | 18 + sdks/csharp/tests/sdk-test-client/Program.cs | 388 ++++++++++++++++++ .../sdk-test-client/sdk-test-client.csproj | 18 + sdks/csharp/tests/sdk_csharp.rs | 104 +++++ 13 files changed, 706 insertions(+), 5 deletions(-) create mode 100644 sdks/csharp/Cargo.toml create mode 100644 sdks/csharp/tests/build-client.sh create mode 100644 sdks/csharp/tests/connect-disconnect-client/Program.cs create mode 100644 sdks/csharp/tests/connect-disconnect-client/connect-disconnect-client.csproj create mode 100644 sdks/csharp/tests/sdk-test-client/Program.cs create mode 100644 sdks/csharp/tests/sdk-test-client/sdk-test-client.csproj create mode 100644 sdks/csharp/tests/sdk_csharp.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39b4a2458e..ddd504f2a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1164,6 +1164,9 @@ jobs: # Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules). ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime + - name: Run C# SDK harness tests + run: cargo test -p sdk-csharp-test-harness --test sdk_csharp + - name: Check quickstart-chat bindings are up to date run: | for dotnet_version in 8 10; do diff --git a/Cargo.lock b/Cargo.lock index bb5abb64fdd..2f85f134076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7176,6 +7176,14 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdk-csharp-test-harness" +version = "2.8.0" +dependencies = [ + "serial_test", + "spacetimedb-testing", +] + [[package]] name = "sdk-test-case-conversion" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c31354942a5..beb34d2103b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/sats", "crates/schema", "crates/smoketests", + "sdks/csharp", "sdks/rust", "sdks/unreal", "crates/snapshot", diff --git a/sdks/csharp/Cargo.toml b/sdks/csharp/Cargo.toml new file mode 100644 index 00000000000..e97f1a04e40 --- /dev/null +++ b/sdks/csharp/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "sdk-csharp-test-harness" +version.workspace = true +edition.workspace = true +license-file = "LICENSE.txt" +description = "A C# SDK test harness for SpacetimeDB clients" + +[dev-dependencies] +spacetimedb-testing = { path = "../../crates/testing" } +serial_test.workspace = true + +[lints] +workspace = true diff --git a/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj b/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj index 8039f5a7b17..01c21891b3a 100644 --- a/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj +++ b/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj @@ -18,7 +18,7 @@ https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk 2.8.0 2.8.0 - $(DefaultItemExcludes);*~/** + $(DefaultItemExcludes);*~/**;tests/** obj~/godot/packages true $(DefineConstants);GODOT diff --git a/sdks/csharp/SpacetimeDB.ClientSDK.csproj b/sdks/csharp/SpacetimeDB.ClientSDK.csproj index 6bc0a267e9c..cb92a34c759 100644 --- a/sdks/csharp/SpacetimeDB.ClientSDK.csproj +++ b/sdks/csharp/SpacetimeDB.ClientSDK.csproj @@ -18,7 +18,7 @@ https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk 2.8.0 2.8.0 - $(DefaultItemExcludes);*~/** + $(DefaultItemExcludes);*~/**;tests/** packages true diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 5847f5728df..7cb56e31b57 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -103,13 +103,15 @@ public abstract class RemoteTableHandleBase : RemoteBase, IRe // and therefore avoids using reflection when initializing the row object. public abstract class IndexBase - where Column : IEquatable + // where Column : IEquatable // TODO: Revisit. Enums don't satisfy the `IEquatable` constraint. It shouldn't be needed though. + where Column : notnull { protected abstract Column GetKey(Row row); } public abstract class UniqueIndexBase : IndexBase - where Column : IEquatable + // where Column : IEquatable // TODO: Revisit. Enums don't satisfy the `IEquatable` constraint. It shouldn't be needed though: `Dictionary` does not require `TKey : IEquatable`; it uses `EqualityComparer.Default`. + where Column : notnull { private readonly Dictionary cache = new(); @@ -123,7 +125,8 @@ public UniqueIndexBase(RemoteTableHandleBase table) } public abstract class BTreeIndexBase : IndexBase - where Column : IEquatable, IComparable + // where Column : IEquatable, IComparable // TODO: Revisit. Enums don't satisfy the `IEquatable` constraint. It shouldn't be needed though: `Dictionary` does not require `TKey : IEquatable`; it uses `EqualityComparer.Default`. And if we change it to `SortedDictionary`, it uses `Comparer.Default`. + where Column : notnull { // TODO: change to SortedDictionary when adding support for range queries. private readonly Dictionary> cache = new(); diff --git a/sdks/csharp/tests/build-client.sh b/sdks/csharp/tests/build-client.sh new file mode 100644 index 00000000000..3ed8ada533e --- /dev/null +++ b/sdks/csharp/tests/build-client.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +dotnet build "$REPO_ROOT/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj" \ + -c Release \ + -p:TargetFramework=net8.0 \ + -p:NuGetAudit=false \ + -p:RestoreIgnoreFailedSources=true + +dotnet build \ + -p:NuGetAudit=false \ + -p:RestoreIgnoreFailedSources=true diff --git a/sdks/csharp/tests/connect-disconnect-client/Program.cs b/sdks/csharp/tests/connect-disconnect-client/Program.cs new file mode 100644 index 00000000000..e75997582d4 --- /dev/null +++ b/sdks/csharp/tests/connect-disconnect-client/Program.cs @@ -0,0 +1,129 @@ +using System; +using System.Linq; +using System.Threading; +using SpacetimeDB; +using SpacetimeDB.Types; + +const string DbNameEnvVar = "SPACETIME_SDK_TEST_DB_NAME"; +const string ServerUrlEnvVar = "SPACETIME_SDK_TEST_SERVER_URL"; + +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +{ + Console.Error.WriteLine(eventArgs.ExceptionObject); + Environment.Exit(1); +}; + +var dbName = Environment.GetEnvironmentVariable(DbNameEnvVar) ?? throw new InvalidOperationException($"{DbNameEnvVar} is not set"); +var serverUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar) ?? "http://localhost:3000"; + +DbConnection db = null!; +var connected = false; +var connectedRowSeen = false; +var disconnected = false; +Identity? firstIdentity = null; + +db = DbConnection + .Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .OnConnect((conn, identity, _) => + { + if (identity != conn.Identity) + { + throw new Exception("Connection identity callback did not match connection state"); + } + firstIdentity = identity; + conn.SubscriptionBuilder() + .OnApplied(_ => + { + if (conn.Db.Connected.Count != 1) + { + throw new Exception($"Expected one connected row, got {conn.Db.Connected.Count}"); + } + + var row = conn.Db.Connected.Iter().Single(); + if (row.Identity != firstIdentity) + { + throw new Exception("Connected row identity did not match first connection identity"); + } + + connectedRowSeen = true; + conn.Disconnect(); + }) + .OnError((_, err) => throw err) + .AddQuery(qb => qb.From.Connected()) + .Subscribe(); + connected = true; + }) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + if (err != null) + { + throw err; + } + disconnected = true; + }) + .Build(); + +FrameTickUntil(() => connected && connectedRowSeen && disconnected); +db.Disconnect(); + +DbConnection reconnectDb = null!; +var reconnected = false; +var disconnectedRowSeen = false; + +reconnectDb = DbConnection + .Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .OnConnect((conn, _, _) => + { + conn.SubscriptionBuilder() + .OnApplied(_ => + { + if (conn.Db.Disconnected.Count != 1) + { + throw new Exception($"Expected one disconnected row, got {conn.Db.Disconnected.Count}"); + } + + var row = conn.Db.Disconnected.Iter().Single(); + if (row.Identity != firstIdentity) + { + throw new Exception("Disconnected row identity did not match first connection identity"); + } + + disconnectedRowSeen = true; + }) + .OnError((_, err) => throw err) + .AddQuery(qb => qb.From.Disconnected()) + .Subscribe(); + reconnected = true; + }) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + if (err != null) + { + throw err; + } + }) + .Build(); + +FrameTickUntil(() => reconnected && disconnectedRowSeen, reconnectDb); +reconnectDb.Disconnect(); + +void FrameTickUntil(Func isComplete, DbConnection? connection = null, int timeoutSeconds = 20) +{ + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + connection ??= db; + while (!isComplete()) + { + connection.FrameTick(); + Thread.Sleep(25); + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"Timed out after {timeoutSeconds} seconds"); + } + } +} diff --git a/sdks/csharp/tests/connect-disconnect-client/connect-disconnect-client.csproj b/sdks/csharp/tests/connect-disconnect-client/connect-disconnect-client.csproj new file mode 100644 index 00000000000..9f5de8e479f --- /dev/null +++ b/sdks/csharp/tests/connect-disconnect-client/connect-disconnect-client.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + ../../../../crates/bindings-csharp/BSATN.Runtime/bin/Release/net8.0/SpacetimeDB.BSATN.Runtime.dll + + + + + diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs new file mode 100644 index 00000000000..7cbe392b400 --- /dev/null +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -0,0 +1,388 @@ +using System; +using System.Linq; +using System.Threading; +using SpacetimeDB; +using SpacetimeDB.Types; + +const string DbNameEnvVar = "SPACETIME_SDK_TEST_DB_NAME"; +const string ServerUrlEnvVar = "SPACETIME_SDK_TEST_SERVER_URL"; + +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +{ + Console.Error.WriteLine(eventArgs.ExceptionObject); + Environment.Exit(1); +}; + +var testName = args.Length > 0 ? args[0] : throw new ArgumentException("Pass a test name as argv[1]"); +var dbName = Environment.GetEnvironmentVariable(DbNameEnvVar) ?? throw new InvalidOperationException($"{DbNameEnvVar} is not set"); +var serverUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar) ?? "http://localhost:3000"; + +switch (testName) +{ + case "insert-primitive": + RunInsertPrimitive(); + break; + case "delete-primitive": + RunDeletePrimitive(); + break; + case "update-primitive": + RunUpdatePrimitive(); + break; + case "insert-builtin": + RunInsertBuiltin(); + break; + case "insert-vec": + RunInsertVec(); + break; + case "insert-option-some": + RunInsertOptionSome(); + break; + case "insert-option-none": + RunInsertOptionNone(); + break; + case "insert-struct": + RunInsertStruct(); + break; + case "insert-simple-enum": + RunInsertSimpleEnum(); + break; + case "insert-enum-with-payload": + RunInsertEnumWithPayload(); + break; + case "fail-reducer": + RunFailReducer(); + break; + case "caller-always-notified": + RunCallerAlwaysNotified(); + break; + default: + throw new ArgumentException($"Unknown C# SDK harness test: {testName}"); +} + +void RunInsertPrimitive() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU32())); + var inserted = false; + var reducerSeen = false; + + test.Db.Db.OneU32.OnInsert += (_, row) => + { + Require(row.N == 123, $"Expected one_u32.n == 123, got {row.N}"); + inserted = true; + }; + test.Db.Reducers.OnInsertOneU32 += (ctx, n) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 123, $"Expected reducer arg 123, got {n}"); + reducerSeen = true; + }; + + test.Db.Reducers.InsertOneU32(123); + test.FrameTickUntil(() => inserted && reducerSeen); + Require(test.Db.Db.OneU32.Count == 1, $"Expected one_u32 cache count 1, got {test.Db.Db.OneU32.Count}"); +} + +void RunDeletePrimitive() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkU32())); + var inserted = false; + var deleted = false; + + test.Db.Db.PkU32.OnInsert += (_, row) => + { + Require(row.N == 7 && row.Data == 10, "Unexpected pk_u32 insert row"); + inserted = true; + }; + test.Db.Db.PkU32.OnDelete += (_, row) => + { + Require(row.N == 7 && row.Data == 10, "Unexpected pk_u32 delete row"); + deleted = true; + }; + + test.Db.Reducers.InsertPkU32(7, 10); + test.FrameTickUntil(() => inserted); + test.Db.Reducers.DeletePkU32(7); + test.FrameTickUntil(() => deleted); + Require(test.Db.Db.PkU32.Count == 0, $"Expected pk_u32 cache count 0, got {test.Db.Db.PkU32.Count}"); +} + +void RunUpdatePrimitive() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkU32())); + var inserted = false; + var updated = false; + + test.Db.Db.PkU32.OnInsert += (_, row) => + { + if (row.N == 9 && row.Data == 11) + { + inserted = true; + } + }; + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.N == 9 && oldRow.Data == 11, "Unexpected old pk_u32 row"); + Require(newRow.N == 9 && newRow.Data == 12, "Unexpected new pk_u32 row"); + updated = true; + }; + + test.Db.Reducers.InsertPkU32(9, 11); + test.FrameTickUntil(() => inserted); + test.Db.Reducers.UpdatePkU32(9, 12); + test.FrameTickUntil(() => updated); + Require(test.Db.Db.PkU32.N.Find(9)?.Data == 12, "Updated row was not visible through primary-key index"); +} + +void RunInsertBuiltin() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneIdentity()).AddQuery(qb => qb.From.OneUuid())); + var insertedIdentity = false; + var insertedUuid = false; + var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); + + test.Db.Db.OneIdentity.OnInsert += (_, row) => + { + Require(row.I == test.Db.Identity, "Inserted identity did not match connection identity"); + insertedIdentity = true; + }; + test.Db.Db.OneUuid.OnInsert += (_, row) => + { + Require(row.U == uuid, "Inserted UUID did not round-trip"); + insertedUuid = true; + }; + + test.Db.Reducers.InsertCallerOneIdentity(); + test.Db.Reducers.InsertOneUuid(uuid); + test.FrameTickUntil(() => insertedIdentity && insertedUuid); +} + +void RunInsertVec() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.VecI32()).AddQuery(qb => qb.From.VecString())); + var intsInserted = false; + var stringsInserted = false; + + test.Db.Db.VecI32.OnInsert += (_, row) => + { + Require(row.N.SequenceEqual(new[] { -1, 0, 42 }), "VecI32 did not round-trip"); + intsInserted = true; + }; + test.Db.Db.VecString.OnInsert += (_, row) => + { + Require(row.S.SequenceEqual(new[] { "alpha", "beta" }), "VecString did not round-trip"); + stringsInserted = true; + }; + + test.Db.Reducers.InsertVecI32(new() { -1, 0, 42 }); + test.Db.Reducers.InsertVecString(new() { "alpha", "beta" }); + test.FrameTickUntil(() => intsInserted && stringsInserted); +} + +void RunInsertOptionSome() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OptionI32()).AddQuery(qb => qb.From.OptionString())); + var intInserted = false; + var stringInserted = false; + + test.Db.Db.OptionI32.OnInsert += (_, row) => + { + Require(row.N == 42, "OptionI32 Some did not round-trip"); + intInserted = true; + }; + test.Db.Db.OptionString.OnInsert += (_, row) => + { + Require(row.S == "present", "OptionString Some did not round-trip"); + stringInserted = true; + }; + + test.Db.Reducers.InsertOptionI32(42); + test.Db.Reducers.InsertOptionString("present"); + test.FrameTickUntil(() => intInserted && stringInserted); +} + +void RunInsertOptionNone() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OptionI32()).AddQuery(qb => qb.From.OptionString())); + var intInserted = false; + var stringInserted = false; + + test.Db.Db.OptionI32.OnInsert += (_, row) => + { + Require(row.N == null, "OptionI32 None did not round-trip"); + intInserted = true; + }; + test.Db.Db.OptionString.OnInsert += (_, row) => + { + Require(row.S == null, "OptionString None did not round-trip"); + stringInserted = true; + }; + + test.Db.Reducers.InsertOptionI32(null); + test.Db.Reducers.InsertOptionString(null); + test.FrameTickUntil(() => intInserted && stringInserted); +} + +void RunInsertStruct() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneByteStruct())); + var inserted = false; + + test.Db.Db.OneByteStruct.OnInsert += (_, row) => + { + Require(row.S.B == 99, "ByteStruct did not round-trip"); + inserted = true; + }; + + test.Db.Reducers.InsertOneByteStruct(new ByteStruct { B = 99 }); + test.FrameTickUntil(() => inserted); +} + +void RunInsertSimpleEnum() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneSimpleEnum())); + var inserted = false; + + test.Db.Db.OneSimpleEnum.OnInsert += (_, row) => + { + Require(row.E == SimpleEnum.Two, "SimpleEnum did not round-trip"); + inserted = true; + }; + + test.Db.Reducers.InsertOneSimpleEnum(SimpleEnum.Two); + test.FrameTickUntil(() => inserted); +} + +void RunInsertEnumWithPayload() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneEnumWithPayload())); + var inserted = false; + var payload = new EnumWithPayload.U8(17); + + test.Db.Db.OneEnumWithPayload.OnInsert += (_, row) => + { + Require(row.E == payload, "EnumWithPayload did not round-trip"); + inserted = true; + }; + + test.Db.Reducers.InsertOneEnumWithPayload(payload); + test.FrameTickUntil(() => inserted); +} + +void RunFailReducer() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueU8())); + var sawFailure = false; + + test.Db.Reducers.OnInsertUniqueU8 += (ctx, n, data) => + { + if (ctx.Event.Status is Status.Failed) + { + Require(n == 1 && data == 20, "Unexpected failed insert_unique_u8 args"); + sawFailure = true; + } + }; + + test.Db.Reducers.InsertUniqueU8(1, 10); + test.FrameTickUntil(() => test.Db.Db.UniqueU8.Count == 1 || sawFailure); + test.Db.Reducers.InsertUniqueU8(1, 20); + test.FrameTickUntil(() => sawFailure); +} + +void RunCallerAlwaysNotified() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU32())); + var callbackSeen = false; + + test.Db.Reducers.OnNoOpSucceeds += ctx => + { + RequireCommitted(ctx.Event.Status); + callbackSeen = true; + }; + + test.Db.Reducers.NoOpSucceeds(); + test.FrameTickUntil(() => callbackSeen); + Require(test.Db.Db.OneU32.Count == 0, "No-op reducer unexpectedly mutated one_u32"); +} + +HarnessConnection ConnectAndSubscribe(Func buildSubscription) +{ + DbConnection db = null!; + var connected = false; + var applied = false; + + db = DbConnection + .Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .OnConnect((conn, identity, _) => + { + Require(identity == conn.Identity, "Connection identity callback did not match connection state"); + buildSubscription(conn) + .OnApplied(_ => applied = true) + .OnError((_, err) => throw err) + .Subscribe(); + connected = true; + }) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + if (err != null) + { + throw err; + } + + throw new Exception("Connection disconnected unexpectedly"); + }) + .Build(); + + var harness = new HarnessConnection(db); + harness.FrameTickUntil(() => connected && applied); + return harness; +} + +void RequireCommitted(Status status) +{ + if (status is not Status.Committed) + { + throw new Exception($"Expected reducer to commit, got {status}"); + } +} + +void Require(bool condition, string message) +{ + if (!condition) + { + throw new Exception(message); + } +} + +sealed class HarnessConnection : IDisposable +{ + public DbConnection Db { get; } + + public Identity Identity => Db.Identity ?? throw new InvalidOperationException("Connection has no identity yet"); + + public HarnessConnection(DbConnection db) + { + Db = db; + } + + public void FrameTickUntil(Func isComplete, int timeoutSeconds = 20) + { + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (!isComplete()) + { + Db.FrameTick(); + Thread.Sleep(25); + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"Timed out after {timeoutSeconds} seconds"); + } + } + } + + public void Dispose() + { + Db.Disconnect(); + } +} diff --git a/sdks/csharp/tests/sdk-test-client/sdk-test-client.csproj b/sdks/csharp/tests/sdk-test-client/sdk-test-client.csproj new file mode 100644 index 00000000000..9f5de8e479f --- /dev/null +++ b/sdks/csharp/tests/sdk-test-client/sdk-test-client.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + ../../../../crates/bindings-csharp/BSATN.Runtime/bin/Release/net8.0/SpacetimeDB.BSATN.Runtime.dll + + + + + diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs new file mode 100644 index 00000000000..82c4104c186 --- /dev/null +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -0,0 +1,104 @@ +use serial_test::serial; +use spacetimedb_testing::sdk::Test; + +const TEST_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/sdk-test-client"); +const CONNECT_DISCONNECT_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/connect-disconnect-client"); + +fn make_test(subcommand: &str) -> Test { + Test::builder() + .with_name(format!("csharp-client-{subcommand}")) + .with_module("sdk-test-cs") + .with_client(TEST_CLIENT) + .with_language("csharp") + .with_bindings_dir("module_bindings") + .with_compile_command("bash ../build-client.sh") + .with_run_command(format!("dotnet ./bin~/Debug/net8.0/sdk-test-client.dll {subcommand}")) + .build() +} + +#[test] +#[serial(Group1)] +fn insert_primitive() { + make_test("insert-primitive").run(); +} + +#[test] +#[serial(Group1)] +fn delete_primitive() { + make_test("delete-primitive").run(); +} + +#[test] +#[serial(Group1)] +fn update_primitive() { + make_test("update-primitive").run(); +} + +#[test] +#[serial(Group1)] +fn insert_builtin() { + make_test("insert-builtin").run(); +} + +#[test] +#[serial(Group2)] +fn insert_vec() { + make_test("insert-vec").run(); +} + +#[test] +#[serial(Group2)] +fn insert_option_some() { + make_test("insert-option-some").run(); +} + +#[test] +#[serial(Group2)] +fn insert_option_none() { + make_test("insert-option-none").run(); +} + +#[test] +#[serial(Group2)] +fn insert_struct() { + make_test("insert-struct").run(); +} + +#[test] +#[serial(Group3)] +fn insert_simple_enum() { + make_test("insert-simple-enum").run(); +} + +#[test] +#[serial(Group3)] +fn insert_enum_with_payload() { + make_test("insert-enum-with-payload").run(); +} + +#[test] +#[serial(Group3)] +fn fail_reducer() { + make_test("fail-reducer").run(); +} + +#[test] +#[serial(Group3)] +fn caller_always_notified() { + make_test("caller-always-notified").run(); +} + +#[test] +#[serial(Group4)] +fn connect_disconnect_callbacks() { + Test::builder() + .with_name("csharp-client-connect-disconnect-callbacks") + .with_module("sdk-test-connect-disconnect-cs") + .with_client(CONNECT_DISCONNECT_CLIENT) + .with_language("csharp") + .with_bindings_dir("module_bindings") + .with_compile_command("bash ../build-client.sh") + .with_run_command("dotnet ./bin~/Debug/net8.0/connect-disconnect-client.dll") + .build() + .run(); +} From eb3410b740a7e1cd838eaf3a350f7dff523434d9 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 09:39:22 -0500 Subject: [PATCH 02/18] Add more test coverage --- sdks/csharp/tests/sdk-test-client/Program.cs | 1225 ++++++++++++++++-- sdks/csharp/tests/sdk_csharp.rs | 235 +++- 2 files changed, 1307 insertions(+), 153 deletions(-) diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index 7cbe392b400..942d3eb84aa 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using SpacetimeDB; @@ -22,14 +24,68 @@ case "insert-primitive": RunInsertPrimitive(); break; + case "subscribe-and-unsubscribe": + RunSubscribeAndUnsubscribe(); + break; + case "subscription-error-smoke-test": + RunSubscriptionErrorSmokeTest(); + break; case "delete-primitive": RunDeletePrimitive(); break; case "update-primitive": RunUpdatePrimitive(); break; - case "insert-builtin": - RunInsertBuiltin(); + case "insert-identity": + RunInsertIdentity(); + break; + case "insert-caller-identity": + RunInsertCallerIdentity(); + break; + case "delete-identity": + RunDeleteIdentity(); + break; + case "update-identity": + RunUpdateIdentity(); + break; + case "insert-connection-id": + RunInsertConnectionId(); + break; + case "insert-caller-connection-id": + RunInsertCallerConnectionId(); + break; + case "delete-connection-id": + RunDeleteConnectionId(); + break; + case "update-connection-id": + RunUpdateConnectionId(); + break; + case "insert-timestamp": + RunInsertTimestamp(); + break; + case "insert-call-timestamp": + RunInsertCallTimestamp(); + break; + case "insert-uuid": + RunInsertUuid(); + break; + case "insert-call-uuid-v4": + RunInsertCallUuidV4(); + break; + case "insert-call-uuid-v7": + RunInsertCallUuidV7(); + break; + case "delete-uuid": + RunDeleteUuid(); + break; + case "update-uuid": + RunUpdateUuid(); + break; + case "on-reducer": + RunOnReducer(); + break; + case "fail-reducer": + RunFailReducer(); break; case "insert-vec": RunInsertVec(); @@ -49,205 +105,514 @@ case "insert-enum-with-payload": RunInsertEnumWithPayload(); break; - case "fail-reducer": - RunFailReducer(); + case "insert-delete-large-table": + RunInsertDeleteLargeTable(); + break; + case "insert-primitives-as-strings": + RunInsertPrimitivesAsStrings(); + break; + case "should-fail": + throw new Exception("intentional failure for harness should_panic coverage"); + case "reauth": + RunReauth(); + break; + case "reconnect-different-connection-id": + RunReconnectDifferentConnectionId(); break; case "caller-always-notified": RunCallerAlwaysNotified(); break; + case "caller-alice-receives-reducer-callback-but-not-bob": + RunCallerAliceReceivesReducerCallbackButNotBob(); + break; + case "row-deduplication": + RunRowDeduplication(); + break; + case "row-deduplication-join-r-and-s": + RunRowDeduplicationJoinRAndS(); + break; + case "row-deduplication-r-join-s-and-r-joint": + RunRowDeduplicationRJoinSAndRJoinT(); + break; + case "test-lhs-join-update": + RunLhsJoinUpdate(disjoint: false); + break; + case "test-lhs-join-update-disjoint-queries": + RunLhsJoinUpdate(disjoint: true); + break; + case "two-different-compression-algos": + RunTwoDifferentCompressionAlgos(); + break; + case "test-parameterized-subscription": + RunParameterizedSubscription(); + break; + case "test-rls-subscription": + RunRlsSubscription(); + break; + case "indexed-simple-enum": + RunIndexedSimpleEnum(); + break; + case "overlapping-subscriptions": + RunOverlappingSubscriptions(); + break; + case "sorted-uuids-insert": + RunSortedUuidsInsert(); + break; default: throw new ArgumentException($"Unknown C# SDK harness test: {testName}"); } void RunInsertPrimitive() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU32())); - var inserted = false; - var reducerSeen = false; + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() + .AddQuery(qb => qb.From.OneU8()) + .AddQuery(qb => qb.From.OneU16()) + .AddQuery(qb => qb.From.OneU32()) + .AddQuery(qb => qb.From.OneU64()) + .AddQuery(qb => qb.From.OneI8()) + .AddQuery(qb => qb.From.OneI16()) + .AddQuery(qb => qb.From.OneI32()) + .AddQuery(qb => qb.From.OneI64()) + .AddQuery(qb => qb.From.OneBool()) + .AddQuery(qb => qb.From.OneF32()) + .AddQuery(qb => qb.From.OneF64()) + .AddQuery(qb => qb.From.OneString())); - test.Db.Db.OneU32.OnInsert += (_, row) => - { - Require(row.N == 123, $"Expected one_u32.n == 123, got {row.N}"); - inserted = true; - }; - test.Db.Reducers.OnInsertOneU32 += (ctx, n) => - { - RequireCommitted(ctx.Event.Status); - Require(n == 123, $"Expected reducer arg 123, got {n}"); - reducerSeen = true; - }; + var remaining = 12; + void Seen() => remaining--; + + test.Db.Db.OneU8.OnInsert += (_, row) => { Require(row.N == 1, "OneU8 did not round-trip"); Seen(); }; + test.Db.Db.OneU16.OnInsert += (_, row) => { Require(row.N == 2, "OneU16 did not round-trip"); Seen(); }; + test.Db.Db.OneU32.OnInsert += (_, row) => { Require(row.N == 3, "OneU32 did not round-trip"); Seen(); }; + test.Db.Db.OneU64.OnInsert += (_, row) => { Require(row.N == 4, "OneU64 did not round-trip"); Seen(); }; + test.Db.Db.OneI8.OnInsert += (_, row) => { Require(row.N == -1, "OneI8 did not round-trip"); Seen(); }; + test.Db.Db.OneI16.OnInsert += (_, row) => { Require(row.N == -2, "OneI16 did not round-trip"); Seen(); }; + test.Db.Db.OneI32.OnInsert += (_, row) => { Require(row.N == -3, "OneI32 did not round-trip"); Seen(); }; + test.Db.Db.OneI64.OnInsert += (_, row) => { Require(row.N == -4, "OneI64 did not round-trip"); Seen(); }; + test.Db.Db.OneBool.OnInsert += (_, row) => { Require(row.B, "OneBool did not round-trip"); Seen(); }; + test.Db.Db.OneF32.OnInsert += (_, row) => { Require(Math.Abs(row.F - 1.25f) < 0.001f, "OneF32 did not round-trip"); Seen(); }; + test.Db.Db.OneF64.OnInsert += (_, row) => { Require(Math.Abs(row.F - 2.5) < 0.001, "OneF64 did not round-trip"); Seen(); }; + test.Db.Db.OneString.OnInsert += (_, row) => { Require(row.S == "hello", "OneString did not round-trip"); Seen(); }; + + test.Db.Reducers.InsertOneU8(1); + test.Db.Reducers.InsertOneU16(2); + test.Db.Reducers.InsertOneU32(3); + test.Db.Reducers.InsertOneU64(4); + test.Db.Reducers.InsertOneI8(-1); + test.Db.Reducers.InsertOneI16(-2); + test.Db.Reducers.InsertOneI32(-3); + test.Db.Reducers.InsertOneI64(-4); + test.Db.Reducers.InsertOneBool(true); + test.Db.Reducers.InsertOneF32(1.25f); + test.Db.Reducers.InsertOneF64(2.5); + test.Db.Reducers.InsertOneString("hello"); + test.FrameTickUntil(() => remaining == 0); +} + +void RunSubscribeAndUnsubscribe() +{ + using var test = Connect(); + var ended = false; + var applied = false; + SubscriptionHandle? handle = null; + + test.Db.Reducers.InsertOneU8(1); + handle = test.Db.SubscriptionBuilder() + .OnApplied(ctx => + { + applied = true; + Require(handle is { IsActive: true, IsEnded: false }, "Applied subscription has wrong state"); + Require(ctx.Db.OneU8.Count == 1, "Expected one row after subscription applied"); + handle.UnsubscribeThen(endCtx => + { + Require(endCtx.Db.OneU8.Count == 0, "Expected cache row to be removed by unsubscribe"); + ended = true; + }); + }) + .OnError((_, err) => throw err) + .Subscribe(new[] { "SELECT * FROM one_u8" }); + + Require(!handle.IsActive, "New subscription should not be active yet"); + test.FrameTickUntil(() => applied && ended); +} + +void RunSubscriptionErrorSmokeTest() +{ + using var test = Connect(); + var errored = false; + var handle = test.Db.SubscriptionBuilder() + .OnApplied(_ => throw new Exception("Invalid subscription unexpectedly applied")) + .OnError((_, _) => errored = true) + .Subscribe(new[] { "SELEcCT * FROM one_u8" }); - test.Db.Reducers.InsertOneU32(123); - test.FrameTickUntil(() => inserted && reducerSeen); - Require(test.Db.Db.OneU32.Count == 1, $"Expected one_u32 cache count 1, got {test.Db.Db.OneU32.Count}"); + Require(!handle.IsActive, "Invalid subscription should not be active yet"); + test.FrameTickUntil(() => errored); + Require(handle.IsEnded, "Invalid subscription handle did not end"); } void RunDeletePrimitive() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkU32())); + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueU8())); var inserted = false; var deleted = false; - test.Db.Db.PkU32.OnInsert += (_, row) => + test.Db.Db.UniqueU8.OnInsert += (_, row) => { - Require(row.N == 7 && row.Data == 10, "Unexpected pk_u32 insert row"); + Require(row.N == 7 && row.Data == 10, "Unexpected unique_u8 insert row"); inserted = true; }; - test.Db.Db.PkU32.OnDelete += (_, row) => + test.Db.Db.UniqueU8.OnDelete += (_, row) => { - Require(row.N == 7 && row.Data == 10, "Unexpected pk_u32 delete row"); + Require(row.N == 7 && row.Data == 10, "Unexpected unique_u8 delete row"); deleted = true; }; - test.Db.Reducers.InsertPkU32(7, 10); + test.Db.Reducers.InsertUniqueU8(7, 10); test.FrameTickUntil(() => inserted); - test.Db.Reducers.DeletePkU32(7); + test.Db.Reducers.DeleteUniqueU8(7); test.FrameTickUntil(() => deleted); - Require(test.Db.Db.PkU32.Count == 0, $"Expected pk_u32 cache count 0, got {test.Db.Db.PkU32.Count}"); + Require(test.Db.Db.UniqueU8.Count == 0, $"Expected unique_u8 cache count 0, got {test.Db.Db.UniqueU8.Count}"); } void RunUpdatePrimitive() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkU32())); + ExpectPkU32Update(test, 9, 11, 12); +} + +void RunInsertIdentity() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneIdentity())); + var identity = Identity.FromHexString("0000000000000000000000000000000000000000000000000000000000000001"); var inserted = false; - var updated = false; - test.Db.Db.PkU32.OnInsert += (_, row) => + test.Db.Db.OneIdentity.OnInsert += (_, row) => { - if (row.N == 9 && row.Data == 11) - { - inserted = true; - } + Require(row.I == identity, "Inserted identity did not round-trip"); + inserted = true; }; - test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + + test.Db.Reducers.InsertOneIdentity(identity); + test.FrameTickUntil(() => inserted); +} + +void RunInsertCallerIdentity() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneIdentity())); + var inserted = false; + + test.Db.Db.OneIdentity.OnInsert += (_, row) => { - Require(oldRow.N == 9 && oldRow.Data == 11, "Unexpected old pk_u32 row"); - Require(newRow.N == 9 && newRow.Data == 12, "Unexpected new pk_u32 row"); - updated = true; + Require(row.I == test.Identity, "Inserted caller identity did not match connection identity"); + inserted = true; }; - test.Db.Reducers.InsertPkU32(9, 11); + test.Db.Reducers.InsertCallerOneIdentity(); test.FrameTickUntil(() => inserted); - test.Db.Reducers.UpdatePkU32(9, 12); - test.FrameTickUntil(() => updated); - Require(test.Db.Db.PkU32.N.Find(9)?.Data == 12, "Updated row was not visible through primary-key index"); } -void RunInsertBuiltin() +void RunDeleteIdentity() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneIdentity()).AddQuery(qb => qb.From.OneUuid())); - var insertedIdentity = false; - var insertedUuid = false; - var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueIdentity())); + var inserted = false; + var deleted = false; - test.Db.Db.OneIdentity.OnInsert += (_, row) => + test.Db.Db.UniqueIdentity.OnInsert += (_, row) => { - Require(row.I == test.Db.Identity, "Inserted identity did not match connection identity"); - insertedIdentity = true; + Require(row.I == test.Identity && row.Data == 10, "Unexpected unique_identity insert row"); + inserted = true; }; - test.Db.Db.OneUuid.OnInsert += (_, row) => + test.Db.Db.UniqueIdentity.OnDelete += (_, row) => { - Require(row.U == uuid, "Inserted UUID did not round-trip"); - insertedUuid = true; + Require(row.I == test.Identity && row.Data == 10, "Unexpected unique_identity delete row"); + deleted = true; }; - test.Db.Reducers.InsertCallerOneIdentity(); - test.Db.Reducers.InsertOneUuid(uuid); - test.FrameTickUntil(() => insertedIdentity && insertedUuid); + test.Db.Reducers.InsertUniqueIdentity(test.Identity, 10); + test.FrameTickUntil(() => inserted); + test.Db.Reducers.DeleteUniqueIdentity(test.Identity); + test.FrameTickUntil(() => deleted); } -void RunInsertVec() +void RunUpdateIdentity() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.VecI32()).AddQuery(qb => qb.From.VecString())); - var intsInserted = false; - var stringsInserted = false; + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkIdentity())); + var updated = false; + test.Db.Db.PkIdentity.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.I == test.Identity && oldRow.Data == 10, "Unexpected old pk_identity row"); + Require(newRow.I == test.Identity && newRow.Data == 20, "Unexpected new pk_identity row"); + updated = true; + }; + test.Db.Reducers.InsertPkIdentity(test.Identity, 10); + test.FrameTickUntil(() => test.Db.Db.PkIdentity.Count == 1); + test.Db.Reducers.UpdatePkIdentity(test.Identity, 20); + test.FrameTickUntil(() => updated); +} - test.Db.Db.VecI32.OnInsert += (_, row) => +void RunInsertConnectionId() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneConnectionId())); + var inserted = false; + test.Db.Db.OneConnectionId.OnInsert += (_, row) => { - Require(row.N.SequenceEqual(new[] { -1, 0, 42 }), "VecI32 did not round-trip"); - intsInserted = true; + Require(row.A == test.Db.ConnectionId, "ConnectionId did not round-trip"); + inserted = true; }; - test.Db.Db.VecString.OnInsert += (_, row) => + test.Db.Reducers.InsertOneConnectionId(test.Db.ConnectionId); + test.FrameTickUntil(() => inserted); +} + +void RunInsertCallerConnectionId() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneConnectionId())); + var inserted = false; + test.Db.Db.OneConnectionId.OnInsert += (_, row) => { - Require(row.S.SequenceEqual(new[] { "alpha", "beta" }), "VecString did not round-trip"); - stringsInserted = true; + Require(row.A == test.Db.ConnectionId, "Caller ConnectionId did not match connection state"); + inserted = true; }; + test.Db.Reducers.InsertCallerOneConnectionId(); + test.FrameTickUntil(() => inserted); +} - test.Db.Reducers.InsertVecI32(new() { -1, 0, 42 }); - test.Db.Reducers.InsertVecString(new() { "alpha", "beta" }); - test.FrameTickUntil(() => intsInserted && stringsInserted); +void RunDeleteConnectionId() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueConnectionId())); + var deleted = false; + test.Db.Reducers.InsertUniqueConnectionId(test.Db.ConnectionId, 10); + test.FrameTickUntil(() => test.Db.Db.UniqueConnectionId.Count == 1); + test.Db.Db.UniqueConnectionId.OnDelete += (_, row) => + { + Require(row.A == test.Db.ConnectionId && row.Data == 10, "Unexpected unique_connection_id delete row"); + deleted = true; + }; + test.Db.Reducers.DeleteUniqueConnectionId(test.Db.ConnectionId); + test.FrameTickUntil(() => deleted); } -void RunInsertOptionSome() +void RunUpdateConnectionId() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OptionI32()).AddQuery(qb => qb.From.OptionString())); - var intInserted = false; - var stringInserted = false; + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkConnectionId())); + var updated = false; + test.Db.Db.PkConnectionId.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.A == test.Db.ConnectionId && oldRow.Data == 10, "Unexpected old pk_connection_id row"); + Require(newRow.A == test.Db.ConnectionId && newRow.Data == 20, "Unexpected new pk_connection_id row"); + updated = true; + }; + test.Db.Reducers.InsertPkConnectionId(test.Db.ConnectionId, 10); + test.FrameTickUntil(() => test.Db.Db.PkConnectionId.Count == 1); + test.Db.Reducers.UpdatePkConnectionId(test.Db.ConnectionId, 20); + test.FrameTickUntil(() => updated); +} - test.Db.Db.OptionI32.OnInsert += (_, row) => +void RunInsertTimestamp() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneTimestamp())); + var timestamp = new Timestamp(1_234_567); + var inserted = false; + test.Db.Db.OneTimestamp.OnInsert += (_, row) => { - Require(row.N == 42, "OptionI32 Some did not round-trip"); - intInserted = true; + Require(row.T == timestamp, "Timestamp did not round-trip"); + inserted = true; }; - test.Db.Db.OptionString.OnInsert += (_, row) => + test.Db.Reducers.InsertOneTimestamp(timestamp); + test.FrameTickUntil(() => inserted); +} + +void RunInsertCallTimestamp() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneTimestamp())); + var inserted = false; + test.Db.Db.OneTimestamp.OnInsert += (ctx, row) => { - Require(row.S == "present", "OptionString Some did not round-trip"); - stringInserted = true; + Require(ctx.Event is Event.Reducer, "Expected reducer event for insert_call_timestamp"); + Require(row.T.MicrosecondsSinceUnixEpoch > 0, "Reducer timestamp was not populated"); + inserted = true; }; + test.Db.Reducers.InsertCallTimestamp(); + test.FrameTickUntil(() => inserted); +} - test.Db.Reducers.InsertOptionI32(42); - test.Db.Reducers.InsertOptionString("present"); - test.FrameTickUntil(() => intInserted && stringInserted); +void RunInsertUuid() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneUuid())); + var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); + var inserted = false; + test.Db.Db.OneUuid.OnInsert += (_, row) => + { + Require(row.U == uuid, "UUID did not round-trip"); + inserted = true; + }; + test.Db.Reducers.InsertOneUuid(uuid); + test.FrameTickUntil(() => inserted); } -void RunInsertOptionNone() +void RunInsertCallUuidV4() => RunGeneratedUuid(test => test.Db.Reducers.InsertCallUuidV4()); + +void RunInsertCallUuidV7() => RunGeneratedUuid(test => test.Db.Reducers.InsertCallUuidV7()); + +void RunGeneratedUuid(Action callReducer) { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OptionI32()).AddQuery(qb => qb.From.OptionString())); - var intInserted = false; - var stringInserted = false; + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneUuid())); + var inserted = false; + test.Db.Db.OneUuid.OnInsert += (_, row) => + { + Require(row.U != Uuid.NIL, "Generated UUID was nil"); + inserted = true; + }; + callReducer(test); + test.FrameTickUntil(() => inserted); +} - test.Db.Db.OptionI32.OnInsert += (_, row) => +void RunDeleteUuid() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueUuid())); + var uuid = Uuid.NIL; + var deleted = false; + test.Db.Reducers.InsertUniqueUuid(uuid, 10); + test.FrameTickUntil(() => test.Db.Db.UniqueUuid.Count == 1); + test.Db.Db.UniqueUuid.OnDelete += (_, row) => { - Require(row.N == null, "OptionI32 None did not round-trip"); - intInserted = true; + Require(row.U == uuid && row.Data == 10, "Unexpected unique_uuid delete row"); + deleted = true; }; - test.Db.Db.OptionString.OnInsert += (_, row) => + test.Db.Reducers.DeleteUniqueUuid(uuid); + test.FrameTickUntil(() => deleted); +} + +void RunUpdateUuid() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkUuid())); + var uuid = Uuid.NIL; + var updated = false; + test.Db.Db.PkUuid.OnUpdate += (_, oldRow, newRow) => { - Require(row.S == null, "OptionString None did not round-trip"); - stringInserted = true; + Require(oldRow.U == uuid && oldRow.Data == 10, "Unexpected old pk_uuid row"); + Require(newRow.U == uuid && newRow.Data == 20, "Unexpected new pk_uuid row"); + updated = true; }; + test.Db.Reducers.InsertPkUuid(uuid, 10); + test.FrameTickUntil(() => test.Db.Db.PkUuid.Count == 1); + test.Db.Reducers.UpdatePkUuid(uuid, 20); + test.FrameTickUntil(() => updated); +} - test.Db.Reducers.InsertOptionI32(null); - test.Db.Reducers.InsertOptionString(null); - test.FrameTickUntil(() => intInserted && stringInserted); +void RunOnReducer() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU8())); + var callbackSeen = false; + test.Db.Reducers.OnInsertOneU8 += (ctx, n) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 128, "Unexpected reducer argument"); + Require(test.Db.Db.OneU8.Count == 1, "Reducer callback did not observe inserted row"); + callbackSeen = true; + }; + test.Db.Reducers.InsertOneU8(128); + test.FrameTickUntil(() => callbackSeen); } -void RunInsertStruct() +void RunFailReducer() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneByteStruct())); - var inserted = false; + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkU8())); + var sawSuccess = false; + var sawFailure = false; - test.Db.Db.OneByteStruct.OnInsert += (_, row) => + test.Db.Reducers.OnInsertPkU8 += (ctx, n, data) => { - Require(row.S.B == 99, "ByteStruct did not round-trip"); - inserted = true; + if (ctx.Event.Status is Status.Committed) + { + Require(n == 1 && data == 10, "Unexpected successful insert_pk_u8 args"); + sawSuccess = true; + test.Db.Reducers.InsertPkU8(1, 20); + } + else if (ctx.Event.Status is Status.Failed) + { + Require(n == 1 && data == 20, "Unexpected failed insert_pk_u8 args"); + sawFailure = true; + } }; + test.Db.Reducers.InsertPkU8(1, 10); + test.FrameTickUntil(() => sawSuccess && sawFailure); + Require(test.Db.Db.PkU8.Count == 1, "Failed duplicate primary-key insert mutated the cache"); +} + +void RunInsertVec() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() + .AddQuery(qb => qb.From.VecI32()) + .AddQuery(qb => qb.From.VecString()) + .AddQuery(qb => qb.From.VecUuid())); + var remaining = 3; + var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); + test.Db.Db.VecI32.OnInsert += (_, row) => { Require(row.N.SequenceEqual(new[] { -1, 0, 42 }), "VecI32 did not round-trip"); remaining--; }; + test.Db.Db.VecString.OnInsert += (_, row) => { Require(row.S.SequenceEqual(new[] { "alpha", "beta" }), "VecString did not round-trip"); remaining--; }; + test.Db.Db.VecUuid.OnInsert += (_, row) => { Require(row.U.SequenceEqual(new[] { uuid }), "VecUuid did not round-trip"); remaining--; }; + test.Db.Reducers.InsertVecI32(new() { -1, 0, 42 }); + test.Db.Reducers.InsertVecString(new() { "alpha", "beta" }); + test.Db.Reducers.InsertVecUuid(new() { uuid }); + test.FrameTickUntil(() => remaining == 0); +} + +void RunInsertOptionSome() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() + .AddQuery(qb => qb.From.OptionI32()) + .AddQuery(qb => qb.From.OptionString()) + .AddQuery(qb => qb.From.OptionUuid())); + var remaining = 3; + var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); + test.Db.Db.OptionI32.OnInsert += (_, row) => { Require(row.N == 42, "OptionI32 Some did not round-trip"); remaining--; }; + test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == "present", "OptionString Some did not round-trip"); remaining--; }; + test.Db.Db.OptionUuid.OnInsert += (_, row) => { Require(row.U == uuid, "OptionUuid Some did not round-trip"); remaining--; }; + test.Db.Reducers.InsertOptionI32(42); + test.Db.Reducers.InsertOptionString("present"); + test.Db.Reducers.InsertOptionUuid(uuid); + test.FrameTickUntil(() => remaining == 0); +} + +void RunInsertOptionNone() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() + .AddQuery(qb => qb.From.OptionI32()) + .AddQuery(qb => qb.From.OptionString()) + .AddQuery(qb => qb.From.OptionUuid())); + var remaining = 3; + test.Db.Db.OptionI32.OnInsert += (_, row) => { Require(row.N == null, "OptionI32 None did not round-trip"); remaining--; }; + test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == null, "OptionString None did not round-trip"); remaining--; }; + test.Db.Db.OptionUuid.OnInsert += (_, row) => { Require(row.U == null, "OptionUuid None did not round-trip"); remaining--; }; + test.Db.Reducers.InsertOptionI32(null); + test.Db.Reducers.InsertOptionString(null); + test.Db.Reducers.InsertOptionUuid(null); + test.FrameTickUntil(() => remaining == 0); +} + +void RunInsertStruct() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() + .AddQuery(qb => qb.From.OneByteStruct()) + .AddQuery(qb => qb.From.OneEveryPrimitiveStruct())); + var remaining = 2; + var primitive = EveryPrimitiveStructValue(test); + test.Db.Db.OneByteStruct.OnInsert += (_, row) => { Require(row.S.B == 99, "ByteStruct did not round-trip"); remaining--; }; + test.Db.Db.OneEveryPrimitiveStruct.OnInsert += (_, row) => { Require(row.S == primitive, "EveryPrimitiveStruct did not round-trip"); remaining--; }; test.Db.Reducers.InsertOneByteStruct(new ByteStruct { B = 99 }); - test.FrameTickUntil(() => inserted); + test.Db.Reducers.InsertOneEveryPrimitiveStruct(primitive); + test.FrameTickUntil(() => remaining == 0); } void RunInsertSimpleEnum() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneSimpleEnum())); var inserted = false; - test.Db.Db.OneSimpleEnum.OnInsert += (_, row) => { Require(row.E == SimpleEnum.Two, "SimpleEnum did not round-trip"); inserted = true; }; - test.Db.Reducers.InsertOneSimpleEnum(SimpleEnum.Two); test.FrameTickUntil(() => inserted); } @@ -257,63 +622,445 @@ void RunInsertEnumWithPayload() using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneEnumWithPayload())); var inserted = false; var payload = new EnumWithPayload.U8(17); - test.Db.Db.OneEnumWithPayload.OnInsert += (_, row) => { Require(row.E == payload, "EnumWithPayload did not round-trip"); inserted = true; }; - test.Db.Reducers.InsertOneEnumWithPayload(payload); test.FrameTickUntil(() => inserted); } -void RunFailReducer() +void RunInsertDeleteLargeTable() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueU8())); - var sawFailure = false; + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.LargeTable())); + var large = LargeTableValue(test); + var inserted = false; + var deleted = false; + test.Db.Db.LargeTable.OnInsert += (_, row) => + { + Require(row == large, "LargeTable insert did not round-trip"); + inserted = true; + CallDeleteLargeTable(test, large); + }; + test.Db.Db.LargeTable.OnDelete += (_, row) => + { + Require(row == large, "LargeTable delete did not round-trip"); + deleted = true; + }; + CallInsertLargeTable(test, large); + test.FrameTickUntil(() => inserted && deleted); +} - test.Db.Reducers.OnInsertUniqueU8 += (ctx, n, data) => +void RunInsertPrimitivesAsStrings() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.VecString())); + var primitive = EveryPrimitiveStructValue(test); + var expected = new[] { - if (ctx.Event.Status is Status.Failed) - { - Require(n == 1 && data == 20, "Unexpected failed insert_unique_u8 args"); - sawFailure = true; - } + primitive.A.ToString(), + primitive.B.ToString(), + primitive.C.ToString(), + primitive.D.ToString(), + primitive.E.ToString(), + primitive.F.ToString(), + primitive.G.ToString(), + primitive.H.ToString(), + primitive.I.ToString(), + primitive.J.ToString(), + primitive.K.ToString(), + primitive.L.ToString(), + primitive.M.ToString().ToLowerInvariant(), + primitive.N.ToString(), + primitive.O.ToString(), + primitive.P, + primitive.Q.ToString(), + primitive.R.ToString(), + primitive.S.ToString(), + primitive.T.ToString(), + primitive.U.ToString(), + }; + var inserted = false; + test.Db.Db.VecString.OnInsert += (_, row) => + { + Require(row.S.SequenceEqual(expected), "Primitive string conversion did not round-trip"); + inserted = true; }; + test.Db.Reducers.InsertPrimitivesAsStrings(primitive); + test.FrameTickUntil(() => inserted); +} + +void RunReauth() +{ + var tokenPath = Path.Combine(Path.GetTempPath(), $"spacetimedb-csharp-sdk-test-{dbName}.token"); + string? token = null; + using (var first = Connect(onConnect: (_, _, receivedToken) => + { + token = receivedToken; + File.WriteAllText(tokenPath, receivedToken); + })) + { + first.FrameTickUntil(() => token != null); + } + + token = File.ReadAllText(tokenPath); + using var second = Connect(token: token, onConnect: (_, identity, receivedToken) => + { + Require(receivedToken == token, "Reauth connection returned a different token"); + Require(identity != default, "Reauth connection returned default identity"); + }); + second.FrameTickUntil(() => second.Db.Identity != null); +} + +void RunReconnectDifferentConnectionId() +{ + ConnectionId? firstConnectionId = null; + using (var first = Connect(allowCleanDisconnect: true)) + { + firstConnectionId = first.Db.ConnectionId; + } - test.Db.Reducers.InsertUniqueU8(1, 10); - test.FrameTickUntil(() => test.Db.Db.UniqueU8.Count == 1 || sawFailure); - test.Db.Reducers.InsertUniqueU8(1, 20); - test.FrameTickUntil(() => sawFailure); + using var second = Connect(); + second.FrameTickUntil(() => second.Db.Identity != null); + Require(second.Db.ConnectionId != firstConnectionId, "Reconnect reused the prior connection id"); } void RunCallerAlwaysNotified() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU32())); var callbackSeen = false; - test.Db.Reducers.OnNoOpSucceeds += ctx => { RequireCommitted(ctx.Event.Status); callbackSeen = true; }; - test.Db.Reducers.NoOpSucceeds(); test.FrameTickUntil(() => callbackSeen); Require(test.Db.Db.OneU32.Count == 0, "No-op reducer unexpectedly mutated one_u32"); } -HarnessConnection ConnectAndSubscribe(Func buildSubscription) +void RunCallerAliceReceivesReducerCallbackButNotBob() +{ + using var alice = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU8()).AddQuery(qb => qb.From.OneU16())); + using var bob = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneU8()).AddQuery(qb => qb.From.OneU16())); + Require(alice.Identity != bob.Identity, "Alice and Bob should have distinct identities"); + + var aliceRows = 0; + var bobRows = 0; + var aliceReducerCallback = false; + var bobReducerCallback = false; + alice.Db.Db.OneU8.OnInsert += (_, row) => { Require(row.N == 42, "Alice saw wrong one_u8 value"); aliceRows++; }; + bob.Db.Db.OneU8.OnInsert += (_, row) => { Require(row.N == 42, "Bob saw wrong one_u8 value"); bobRows++; }; + alice.Db.Db.OneU16.OnInsert += (_, row) => { Require(row.N == 24, "Alice saw wrong one_u16 value"); aliceRows++; }; + bob.Db.Db.OneU16.OnInsert += (_, row) => { Require(row.N == 24, "Bob saw wrong one_u16 value"); bobRows++; }; + alice.Db.Reducers.OnInsertOneU8 += (ctx, n) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 42, "Alice reducer callback saw wrong argument"); + aliceReducerCallback = true; + }; + bob.Db.Reducers.OnInsertOneU8 += (_, _) => bobReducerCallback = true; + + alice.Db.Reducers.InsertOneU8(42); + alice.Db.Reducers.InsertOneU16(24); + FrameTickUntil(new[] { alice, bob }, () => aliceRows == 2 && bobRows == 2 && aliceReducerCallback); + Require(!bobReducerCallback, "Bob received Alice's reducer callback"); +} + +void RunRowDeduplication() +{ + using var test = ConnectAndSubscribeSql( + "SELECT * FROM pk_u32 WHERE n < 100", + "SELECT * FROM pk_u32 WHERE n < 200"); + var ins24 = Once("insert 24"); + var ins42 = Once("insert 42"); + var del24 = Once("delete 24"); + var upd42 = Once("update 42"); + + test.Db.Db.PkU32.OnInsert += (_, row) => + { + if (row.N == 24) + { + ins24.Invoke(); + test.Db.Reducers.DeletePkU32(24); + } + else if (row.N == 42) + { + ins42.Invoke(); + test.Db.Reducers.UpdatePkU32(42, 0xfeeb); + } + else + { + throw new Exception($"Unexpected pk_u32 insert {row.N}"); + } + }; + test.Db.Db.PkU32.OnDelete += (_, row) => + { + Require(row.N == 24, "Only row 24 should be deleted"); + del24.Invoke(); + }; + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.N == 42 && oldRow.Data == 0xbeef && newRow.N == 42 && newRow.Data == 0xfeeb, "Unexpected pk_u32 update"); + upd42.Invoke(); + }; + + test.Db.Reducers.InsertPkU32(24, 0xbeef); + test.Db.Reducers.InsertPkU32(42, 0xbeef); + test.FrameTickUntil(() => ins24.Done && ins42.Done && del24.Done && upd42.Done); + Require(test.Db.Db.PkU32.Count == 1, "Deduplicated cache should contain one row"); +} + +void RunRowDeduplicationJoinRAndS() +{ + using var test = ConnectAndSubscribeSql( + "SELECT * FROM pk_u32", + "SELECT unique_u32.* FROM unique_u32 JOIN pk_u32 ON unique_u32.n = pk_u32.n"); + var pkInsert = false; + var pkUpdate = false; + var uniqueInsert = false; + test.Db.Db.PkU32.OnInsert += (_, row) => + { + Require(row.N == 42 && row.Data == 50, "Unexpected pk_u32 insert"); + pkInsert = true; + test.Db.Reducers.InsertUniqueU32UpdatePkU32(42, 0xbeef, 100); + }; + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.N == 42 && oldRow.Data == 50 && newRow.N == 42 && newRow.Data == 100, "Unexpected pk_u32 update"); + pkUpdate = true; + }; + test.Db.Db.UniqueU32.OnInsert += (_, row) => + { + Require(row.N == 42 && row.Data == 0xbeef, "Unexpected unique_u32 insert"); + uniqueInsert = true; + }; + test.Db.Db.UniqueU32.OnDelete += (_, _) => throw new Exception("unique_u32 should not be deleted"); + test.Db.Reducers.InsertPkU32(42, 50); + test.FrameTickUntil(() => pkInsert && pkUpdate && uniqueInsert); +} + +void RunRowDeduplicationRJoinSAndRJoinT() +{ + using var test = ConnectAndSubscribeSql( + "SELECT * FROM pk_u32", + "SELECT * FROM pk_u32_two", + "SELECT unique_u32.* FROM unique_u32 JOIN pk_u32 ON unique_u32.n = pk_u32.n", + "SELECT unique_u32.* FROM unique_u32 JOIN pk_u32_two ON unique_u32.n = pk_u32_two.n"); + var pkInsert = false; + var pkDelete = false; + var pkTwoInsert = false; + var uniqueInserts = 0; + test.Db.Reducers.InsertUniqueU32(42, 0xbeef); + test.FrameTickUntil(() => true); + test.Db.Db.PkU32.OnInsert += (_, row) => + { + Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32 insert"); + pkInsert = true; + test.Db.Reducers.DeletePkU32InsertPkU32Two(42, 0xbeef); + }; + test.Db.Db.PkU32.OnDelete += (_, row) => + { + Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32 delete"); + pkDelete = true; + }; + test.Db.Db.PkU32Two.OnInsert += (_, row) => + { + Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32_two insert"); + pkTwoInsert = true; + }; + test.Db.Db.UniqueU32.OnInsert += (_, _) => uniqueInserts++; + test.Db.Reducers.InsertPkU32(42, 0xbeef); + test.FrameTickUntil(() => pkInsert && pkDelete && pkTwoInsert); + Require(uniqueInserts <= 1, $"Expected at most one deduplicated unique_u32 insert, got {uniqueInserts}"); +} + +void RunLhsJoinUpdate(bool disjoint) +{ + var queries = disjoint + ? new[] + { + "SELECT p.* FROM pk_u32 p WHERE n = 1", + "SELECT p.* FROM pk_u32 p JOIN unique_u32 u ON p.n = u.n WHERE u.data > 0 AND u.data < 5 AND u.n != 1", + } + : new[] + { + "SELECT p.* FROM pk_u32 p WHERE n = 1", + "SELECT p.* FROM pk_u32 p JOIN unique_u32 u ON p.n = u.n WHERE u.data > 0 AND u.data < 5", + }; + using var test = ConnectAndSubscribeSql(queries); + var insertedRows = 0; + var update1 = false; + var update2 = false; + test.Db.Db.PkU32.OnInsert += (_, row) => + { + if (row.N is 1 or 2) + { + insertedRows++; + } + }; + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + { + if (oldRow.N == 2 && oldRow.Data == 0 && newRow.N == 2 && newRow.Data == 1) + { + update1 = true; + test.Db.Reducers.UpdatePkU32(2, 0); + } + else if (oldRow.N == 2 && oldRow.Data == 1 && newRow.N == 2 && newRow.Data == 0) + { + update2 = true; + } + }; + test.Db.Reducers.InsertPkU32(1, 0); + test.Db.Reducers.InsertPkU32(2, 0); + test.Db.Reducers.InsertUniqueU32(1, 3); + test.Db.Reducers.InsertUniqueU32(2, 4); + test.FrameTickUntil(() => insertedRows == 2); + test.Db.Reducers.UpdatePkU32(2, 1); + test.FrameTickUntil(() => update1 && update2); +} + +void RunTwoDifferentCompressionAlgos() +{ + var bytes = Enumerable.Range(0, 1 << 15).Select(i => (byte)(i % 251)).ToList(); + using var brotli = ConnectAndSubscribeCompression(Compression.Brotli, bytes); + using var gzip = ConnectAndSubscribeCompression(Compression.Gzip, bytes); + using var none = ConnectAndSubscribeCompression(Compression.None, bytes); + none.Db.Reducers.InsertVecU8(bytes); + FrameTickUntil(new[] { brotli, gzip, none }, () => + brotli.Db.Db.VecU8.Count == 1 && gzip.Db.Db.VecU8.Count == 1 && none.Db.Db.VecU8.Count == 1); +} + +void RunParameterizedSubscription() +{ + using var client0 = ConnectAndSubscribeSql("SELECT * FROM pk_identity WHERE i = :sender"); + using var client1 = ConnectAndSubscribeSql("SELECT * FROM pk_identity WHERE i = :sender"); + var insert0 = false; + var update0 = false; + var insert1 = false; + var update1 = false; + client0.Db.Db.PkIdentity.OnInsert += (_, row) => { Require(row.I == client0.Identity && row.Data == 1, "client0 insert mismatch"); insert0 = true; }; + client0.Db.Db.PkIdentity.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.I == client0.Identity && newRow.I == client0.Identity && oldRow.Data == 1 && newRow.Data == 2, "client0 update mismatch"); update0 = true; }; + client1.Db.Db.PkIdentity.OnInsert += (_, row) => { Require(row.I == client1.Identity && row.Data == 3, "client1 insert mismatch"); insert1 = true; }; + client1.Db.Db.PkIdentity.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.I == client1.Identity && newRow.I == client1.Identity && oldRow.Data == 3 && newRow.Data == 4, "client1 update mismatch"); update1 = true; }; + client0.Db.Reducers.InsertPkIdentity(client0.Identity, 1); + client0.Db.Reducers.UpdatePkIdentity(client0.Identity, 2); + client1.Db.Reducers.InsertPkIdentity(client1.Identity, 3); + client1.Db.Reducers.UpdatePkIdentity(client1.Identity, 4); + FrameTickUntil(new[] { client0, client1 }, () => insert0 && update0 && insert1 && update1); +} + +void RunRlsSubscription() +{ + using var alice = ConnectAndSubscribeSql("SELECT * FROM users"); + using var bob = ConnectAndSubscribeSql("SELECT * FROM users"); + var aliceInserted = false; + var bobInserted = false; + alice.Db.Db.Users.OnInsert += (_, row) => + { + Require(row.Name == "Alice" && row.Identity == alice.Identity, "Alice saw wrong RLS row"); + aliceInserted = true; + }; + bob.Db.Db.Users.OnInsert += (_, row) => + { + Require(row.Name == "Bob" && row.Identity == bob.Identity, "Bob saw wrong RLS row"); + bobInserted = true; + }; + alice.Db.Reducers.InsertUser("Alice", alice.Identity); + bob.Db.Reducers.InsertUser("Bob", bob.Identity); + FrameTickUntil(new[] { alice, bob }, () => aliceInserted && bobInserted); +} + +void RunIndexedSimpleEnum() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.IndexedSimpleEnum())); + var updated = false; + test.Db.Db.IndexedSimpleEnum.OnInsert += (_, row) => + { + if (row.N == SimpleEnum.Two) + { + test.Db.Reducers.UpdateIndexedSimpleEnum(SimpleEnum.Two, SimpleEnum.One); + } + else if (row.N == SimpleEnum.One) + { + updated = true; + } + }; + test.Db.Reducers.InsertIntoIndexedSimpleEnum(SimpleEnum.Two); + test.FrameTickUntil(() => updated); +} + +void RunOverlappingSubscriptions() +{ + using var test = Connect(); + test.Db.Reducers.InsertPkU8(1, 0); + test.FrameTickUntil(() => true); + var applied = false; + test.Db.SubscriptionBuilder() + .OnApplied(ctx => + { + Require(ctx.Db.PkU8.Count == 1, "Overlapping initial subscription should deduplicate matching row"); + applied = true; + }) + .OnError((_, err) => throw err) + .Subscribe(new[] { "SELECT * FROM pk_u8 WHERE n < 100", "SELECT * FROM pk_u8 WHERE n > 0" }); + test.FrameTickUntil(() => applied); + var updated = false; + test.Db.Db.PkU8.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.N == 1 && oldRow.Data == 0 && newRow.N == 1 && newRow.Data == 1, "Overlapping update was wrong"); + updated = true; + }; + test.Db.Reducers.UpdatePkU8(1, 1); + test.FrameTickUntil(() => updated); +} + +void RunSortedUuidsInsert() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkUuid())); + var rows = 0; + var reducerSeen = false; + test.Db.Db.PkUuid.OnInsert += (_, _) => rows++; + test.Db.Reducers.OnSortedUuidsInsert += ctx => + { + RequireCommitted(ctx.Event.Status); + reducerSeen = true; + }; + test.Db.Reducers.SortedUuidsInsert(); + test.FrameTickUntil(() => reducerSeen && rows == 1000, timeoutSeconds: 30); + Require(test.Db.Db.PkUuid.Count == 1000, "Expected 1000 UUID rows"); +} + +void ExpectPkU32Update(HarnessConnection test, uint key, int initialData, int updatedData) +{ + var inserted = false; + var updated = false; + test.Db.Db.PkU32.OnInsert += (_, row) => + { + if (row.N == key && row.Data == initialData) + { + inserted = true; + } + }; + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.N == key && oldRow.Data == initialData, "Unexpected old pk_u32 row"); + Require(newRow.N == key && newRow.Data == updatedData, "Unexpected new pk_u32 row"); + updated = true; + }; + test.Db.Reducers.InsertPkU32(key, initialData); + test.FrameTickUntil(() => inserted); + test.Db.Reducers.UpdatePkU32(key, updatedData); + test.FrameTickUntil(() => updated); +} + +HarnessConnection ConnectAndSubscribe(Func buildSubscription, Compression? compression = null) { DbConnection db = null!; var connected = false; var applied = false; - db = DbConnection - .Builder() - .WithUri(serverUrl) - .WithDatabaseName(dbName) + db = BuildConnection(compression: compression) .OnConnect((conn, identity, _) => { Require(identity == conn.Identity, "Connection identity callback did not match connection state"); @@ -323,23 +1070,167 @@ HarnessConnection ConnectAndSubscribe(Func throw err) - .OnDisconnect((_, err) => - { - if (err != null) - { - throw err; - } + .Build(); - throw new Exception("Connection disconnected unexpectedly"); + var harness = new HarnessConnection(db); + harness.FrameTickUntil(() => connected && applied); + return harness; +} + +HarnessConnection ConnectAndSubscribeSql(params string[] queries) +{ + var connected = false; + var applied = false; + DbConnection db = null!; + db = BuildConnection() + .OnConnect((conn, _, _) => + { + conn.SubscriptionBuilder() + .OnApplied(_ => applied = true) + .OnError((_, err) => throw err) + .Subscribe(queries); + connected = true; }) .Build(); - var harness = new HarnessConnection(db); harness.FrameTickUntil(() => connected && applied); return harness; } +HarnessConnection ConnectAndSubscribeCompression(Compression compression, List expected) +{ + var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.VecU8()), compression); + test.Db.Db.VecU8.OnInsert += (_, row) => + { + Require(row.N.SequenceEqual(expected), $"{compression} subscription received wrong bytes"); + }; + return test; +} + +HarnessConnection Connect( + string? token = null, + bool allowCleanDisconnect = false, + Action? onConnect = null, + Compression? compression = null) +{ + var connected = false; + var builder = BuildConnection(token, allowCleanDisconnect, compression) + .OnConnect((conn, identity, receivedToken) => + { + Require(identity == conn.Identity, "Connection identity callback did not match connection state"); + connected = true; + onConnect?.Invoke(conn, identity, receivedToken); + }); + var harness = new HarnessConnection(builder.Build(), allowCleanDisconnect); + harness.FrameTickUntil(() => connected); + return harness; +} + +DbConnectionBuilder BuildConnection(string? token = null, bool allowCleanDisconnect = false, Compression? compression = null) +{ + var builder = DbConnection + .Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .WithToken(token) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + if (allowCleanDisconnect && err == null) + { + return; + } + throw err ?? new Exception("Connection disconnected unexpectedly"); + }); + if (compression != null) + { + builder.WithCompression(compression.Value); + } + return builder; +} + +EveryPrimitiveStruct EveryPrimitiveStructValue(HarnessConnection test) => new() +{ + A = 1, + B = 2, + C = 3, + D = 4, + E = new U128(0, 5), + F = new U256(new U128(0, 0), new U128(0, 6)), + G = -1, + H = -2, + I = -3, + J = -4, + K = new I128(0, 5), + L = new I256(new U128(0, 0), new U128(0, 6)), + M = true, + N = 1.25f, + O = 2.5, + P = "primitive", + Q = test.Identity, + R = test.Db.ConnectionId, + S = new Timestamp(1_234_567), + T = new TimeDuration(9_876), + U = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"), +}; + +EveryVecStruct EveryVecStructValue(HarnessConnection test) => new() +{ + A = new() { 1 }, + B = new() { 2 }, + C = new() { 3 }, + D = new() { 4 }, + E = new() { new U128(0, 5) }, + F = new() { new U256(new U128(0, 0), new U128(0, 6)) }, + G = new() { -1 }, + H = new() { -2 }, + I = new() { -3 }, + J = new() { -4 }, + K = new() { new I128(0, 5) }, + L = new() { new I256(new U128(0, 0), new U128(0, 6)) }, + M = new() { true }, + N = new() { 1.25f }, + O = new() { 2.5 }, + P = new() { "vec" }, + Q = new() { test.Identity }, + R = new() { test.Db.ConnectionId }, + S = new() { new Timestamp(1_234_567) }, + T = new() { new TimeDuration(9_876) }, + U = new() { Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10") }, +}; + +LargeTable LargeTableValue(HarnessConnection test) => new() +{ + A = 1, + B = 2, + C = 3, + D = 4, + E = new U128(0, 5), + F = new U256(new U128(0, 0), new U128(0, 6)), + G = -1, + H = -2, + I = -3, + J = -4, + K = new I128(0, 5), + L = new I256(new U128(0, 0), new U128(0, 6)), + M = true, + N = 1.25f, + O = 2.5, + P = "large", + Q = SimpleEnum.Two, + R = new EnumWithPayload.Str("payload"), + S = new UnitStruct(), + T = new ByteStruct { B = 9 }, + U = EveryPrimitiveStructValue(test), + V = EveryVecStructValue(test), +}; + +void CallInsertLargeTable(HarnessConnection test, LargeTable row) => + test.Db.Reducers.InsertLargeTable(row.A, row.B, row.C, row.D, row.E, row.F, row.G, row.H, row.I, row.J, row.K, row.L, row.M, row.N, row.O, row.P, row.Q, row.R, row.S, row.T, row.U, row.V); + +void CallDeleteLargeTable(HarnessConnection test, LargeTable row) => + test.Db.Reducers.DeleteLargeTable(row.A, row.B, row.C, row.D, row.E, row.F, row.G, row.H, row.I, row.J, row.K, row.L, row.M, row.N, row.O, row.P, row.Q, row.R, row.S, row.T, row.U, row.V); + void RequireCommitted(Status status) { if (status is not Status.Committed) @@ -356,15 +1247,67 @@ void Require(bool condition, string message) } } +OnceFlag Once(string name) +{ + var seen = false; + return new OnceFlag(() => + { + if (seen) + { + throw new Exception($"{name} callback fired more than once"); + } + seen = true; + }, () => seen); +} + +void FrameTickUntil(IEnumerable connections, Func isComplete, int timeoutSeconds = 20) +{ + var list = connections.ToArray(); + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + while (!isComplete()) + { + foreach (var connection in list) + { + connection.Db.FrameTick(); + } + Thread.Sleep(25); + if (DateTime.UtcNow > deadline) + { + throw new TimeoutException($"Timed out after {timeoutSeconds} seconds"); + } + } +} + +sealed class OnceFlag +{ + private readonly Action mark; + private readonly Func done; + + public OnceFlag(Action mark, Func done) + { + this.mark = mark; + this.done = done; + } + + public bool Done => done(); + + public void Invoke() => mark(); + + public static implicit operator Action(OnceFlag flag) => flag.Invoke; +} + sealed class HarnessConnection : IDisposable { + private readonly bool allowCleanDisconnect; + public DbConnection Db { get; } public Identity Identity => Db.Identity ?? throw new InvalidOperationException("Connection has no identity yet"); - public HarnessConnection(DbConnection db) + public HarnessConnection(DbConnection db, bool allowCleanDisconnect = false) { Db = db; + this.allowCleanDisconnect = allowCleanDisconnect; } public void FrameTickUntil(Func isComplete, int timeoutSeconds = 20) @@ -383,6 +1326,12 @@ public void FrameTickUntil(Func isComplete, int timeoutSeconds = 20) public void Dispose() { - Db.Disconnect(); + try + { + Db.Disconnect(); + } + catch when (allowCleanDisconnect) + { + } } } diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs index 82c4104c186..1e64fb908b3 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -17,79 +17,284 @@ fn make_test(subcommand: &str) -> Test { } #[test] -#[serial(Group1)] +#[serial(CsharpSdk)] fn insert_primitive() { make_test("insert-primitive").run(); } #[test] -#[serial(Group1)] +#[serial(CsharpSdk)] +fn subscribe_and_unsubscribe() { + make_test("subscribe-and-unsubscribe").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn subscription_error_smoke_test() { + make_test("subscription-error-smoke-test").run(); +} + +#[test] +#[serial(CsharpSdk)] fn delete_primitive() { make_test("delete-primitive").run(); } #[test] -#[serial(Group1)] +#[serial(CsharpSdk)] fn update_primitive() { make_test("update-primitive").run(); } #[test] -#[serial(Group1)] -fn insert_builtin() { - make_test("insert-builtin").run(); +#[serial(CsharpSdk)] +fn insert_identity() { + make_test("insert-identity").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_caller_identity() { + make_test("insert-caller-identity").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn delete_identity() { + make_test("delete-identity").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn update_identity() { + make_test("update-identity").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_connection_id() { + make_test("insert-connection-id").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_caller_connection_id() { + make_test("insert-caller-connection-id").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn delete_connection_id() { + make_test("delete-connection-id").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn update_connection_id() { + make_test("update-connection-id").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_timestamp() { + make_test("insert-timestamp").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_call_timestamp() { + make_test("insert-call-timestamp").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_uuid() { + make_test("insert-uuid").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_call_uuid_v4() { + make_test("insert-call-uuid-v4").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_call_uuid_v7() { + make_test("insert-call-uuid-v7").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn delete_uuid() { + make_test("delete-uuid").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn update_uuid() { + make_test("update-uuid").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn on_reducer() { + make_test("on-reducer").run(); } #[test] -#[serial(Group2)] +#[serial(CsharpSdk)] fn insert_vec() { make_test("insert-vec").run(); } #[test] -#[serial(Group2)] +#[serial(CsharpSdk)] fn insert_option_some() { make_test("insert-option-some").run(); } #[test] -#[serial(Group2)] +#[serial(CsharpSdk)] fn insert_option_none() { make_test("insert-option-none").run(); } #[test] -#[serial(Group2)] +#[serial(CsharpSdk)] fn insert_struct() { make_test("insert-struct").run(); } #[test] -#[serial(Group3)] +#[serial(CsharpSdk)] fn insert_simple_enum() { make_test("insert-simple-enum").run(); } #[test] -#[serial(Group3)] +#[serial(CsharpSdk)] fn insert_enum_with_payload() { make_test("insert-enum-with-payload").run(); } #[test] -#[serial(Group3)] +#[serial(CsharpSdk)] fn fail_reducer() { make_test("fail-reducer").run(); } #[test] -#[serial(Group3)] +#[serial(CsharpSdk)] +fn insert_delete_large_table() { + make_test("insert-delete-large-table").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_primitives_as_strings() { + make_test("insert-primitives-as-strings").run(); +} + +#[test] +#[serial(CsharpSdk)] +#[should_panic] +fn should_fail() { + make_test("should-fail").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn reauth() { + make_test("reauth").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn reconnect_different_connection_id() { + make_test("reconnect-different-connection-id").run(); +} + +#[test] +#[serial(CsharpSdk)] fn caller_always_notified() { make_test("caller-always-notified").run(); } #[test] -#[serial(Group4)] +#[serial(CsharpSdk)] +fn caller_alice_receives_reducer_callback_but_not_bob() { + make_test("caller-alice-receives-reducer-callback-but-not-bob").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn row_deduplication() { + make_test("row-deduplication").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn row_deduplication_join_r_and_s() { + make_test("row-deduplication-join-r-and-s").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn row_deduplication_r_join_s_and_r_joint() { + make_test("row-deduplication-r-join-s-and-r-joint").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn test_lhs_join_update() { + make_test("test-lhs-join-update").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn test_lhs_join_update_disjoint_queries() { + make_test("test-lhs-join-update-disjoint-queries").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn two_different_compression_algos() { + make_test("two-different-compression-algos").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn test_parameterized_subscription() { + make_test("test-parameterized-subscription").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn test_rls_subscription() { + make_test("test-rls-subscription").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn indexed_simple_enum() { + make_test("indexed-simple-enum").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn overlapping_subscriptions() { + make_test("overlapping-subscriptions").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn sorted_uuids_insert() { + make_test("sorted-uuids-insert").run(); +} + +#[test] +#[serial(CsharpSdk)] fn connect_disconnect_callbacks() { Test::builder() .with_name("csharp-client-connect-disconnect-callbacks") From 3f4684fff96c637bc11058769a7352d77bca0289 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 11:20:15 -0500 Subject: [PATCH 03/18] Increment coverage --- sdks/csharp/tests/sdk-test-client/Program.cs | 145 +++++++++++++++++++ sdks/csharp/tests/sdk_csharp.rs | 18 +++ 2 files changed, 163 insertions(+) diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index 942d3eb84aa..f2e14b36313 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -30,6 +30,9 @@ case "subscription-error-smoke-test": RunSubscriptionErrorSmokeTest(); break; + case "subscribe-all-select-star": + RunSubscribeAllSelectStar(); + break; case "delete-primitive": RunDeletePrimitive(); break; @@ -140,6 +143,9 @@ case "test-lhs-join-update-disjoint-queries": RunLhsJoinUpdate(disjoint: true); break; + case "test-intra-query-bag-semantics-for-join": + RunIntraQueryBagSemanticsForJoin(); + break; case "two-different-compression-algos": RunTwoDifferentCompressionAlgos(); break; @@ -149,6 +155,9 @@ case "test-rls-subscription": RunRlsSubscription(); break; + case "pk-simple-enum": + RunPkSimpleEnum(); + break; case "indexed-simple-enum": RunIndexedSimpleEnum(); break; @@ -250,6 +259,53 @@ void RunSubscriptionErrorSmokeTest() Require(handle.IsEnded, "Invalid subscription handle did not end"); } +void RunSubscribeAllSelectStar() +{ + using var test = ConnectAndSubscribeAll(); + var remaining = 16; + void Seen() => remaining--; + + var u128 = new U128(0, 5); + var u256 = new U256(new U128(0, 0), new U128(0, 6)); + var i128 = new I128(0, 7); + var i256 = new I256(new U128(0, 0), new U128(0, 8)); + + test.Db.Db.OneU8.OnInsert += (_, row) => { Require(row.N == 1, "OneU8 did not round-trip"); Seen(); }; + test.Db.Db.OneU16.OnInsert += (_, row) => { Require(row.N == 2, "OneU16 did not round-trip"); Seen(); }; + test.Db.Db.OneU32.OnInsert += (_, row) => { Require(row.N == 3, "OneU32 did not round-trip"); Seen(); }; + test.Db.Db.OneU64.OnInsert += (_, row) => { Require(row.N == 4, "OneU64 did not round-trip"); Seen(); }; + test.Db.Db.OneU128.OnInsert += (_, row) => { Require(row.N.Equals(u128), "OneU128 did not round-trip"); Seen(); }; + test.Db.Db.OneU256.OnInsert += (_, row) => { Require(row.N.Equals(u256), "OneU256 did not round-trip"); Seen(); }; + test.Db.Db.OneI8.OnInsert += (_, row) => { Require(row.N == -1, "OneI8 did not round-trip"); Seen(); }; + test.Db.Db.OneI16.OnInsert += (_, row) => { Require(row.N == -2, "OneI16 did not round-trip"); Seen(); }; + test.Db.Db.OneI32.OnInsert += (_, row) => { Require(row.N == -3, "OneI32 did not round-trip"); Seen(); }; + test.Db.Db.OneI64.OnInsert += (_, row) => { Require(row.N == -4, "OneI64 did not round-trip"); Seen(); }; + test.Db.Db.OneI128.OnInsert += (_, row) => { Require(row.N.Equals(i128), "OneI128 did not round-trip"); Seen(); }; + test.Db.Db.OneI256.OnInsert += (_, row) => { Require(row.N.Equals(i256), "OneI256 did not round-trip"); Seen(); }; + test.Db.Db.OneBool.OnInsert += (_, row) => { Require(row.B, "OneBool did not round-trip"); Seen(); }; + test.Db.Db.OneF32.OnInsert += (_, row) => { Require(Math.Abs(row.F - 1.25f) < 0.001f, "OneF32 did not round-trip"); Seen(); }; + test.Db.Db.OneF64.OnInsert += (_, row) => { Require(Math.Abs(row.F - 2.5) < 0.001, "OneF64 did not round-trip"); Seen(); }; + test.Db.Db.OneString.OnInsert += (_, row) => { Require(row.S == "hello", "OneString did not round-trip"); Seen(); }; + + test.Db.Reducers.InsertOneU8(1); + test.Db.Reducers.InsertOneU16(2); + test.Db.Reducers.InsertOneU32(3); + test.Db.Reducers.InsertOneU64(4); + test.Db.Reducers.InsertOneU128(u128); + test.Db.Reducers.InsertOneU256(u256); + test.Db.Reducers.InsertOneI8(-1); + test.Db.Reducers.InsertOneI16(-2); + test.Db.Reducers.InsertOneI32(-3); + test.Db.Reducers.InsertOneI64(-4); + test.Db.Reducers.InsertOneI128(i128); + test.Db.Reducers.InsertOneI256(i256); + test.Db.Reducers.InsertOneBool(true); + test.Db.Reducers.InsertOneF32(1.25f); + test.Db.Reducers.InsertOneF64(2.5); + test.Db.Reducers.InsertOneString("hello"); + test.FrameTickUntil(() => remaining == 0); +} + void RunDeletePrimitive() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueU8())); @@ -920,6 +976,54 @@ void RunLhsJoinUpdate(bool disjoint) test.FrameTickUntil(() => update1 && update2); } +void RunIntraQueryBagSemanticsForJoin() +{ + using var test = ConnectAndSubscribeSql( + "SELECT * FROM btree_u32", + "SELECT pk_u32.* FROM pk_u32 JOIN btree_u32 ON pk_u32.n = btree_u32.n"); + var pkInserts = 0; + var pkDeletes = 0; + var firstBtreeDeleteReducerSeen = false; + var secondBtreeDeleteReducerSeen = false; + + test.Db.Db.PkU32.OnInsert += (_, row) => + { + Require(row.N == 0 && row.Data == 0, "Unexpected pk_u32 insert"); + pkInserts++; + }; + test.Db.Db.PkU32.OnDelete += (_, row) => + { + Require(row.N == 0 && row.Data == 0, "Unexpected pk_u32 delete"); + pkDeletes++; + }; + test.Db.Reducers.OnDeleteFromBtreeU32 += (ctx, rows) => + { + RequireCommitted(ctx.Event.Status); + var deleted = rows.Single(); + if (deleted.Data == 0) + { + Require(pkDeletes == 0, "pk_u32 was deleted while join multiplicity was still positive"); + firstBtreeDeleteReducerSeen = true; + } + else if (deleted.Data == 1) + { + secondBtreeDeleteReducerSeen = true; + } + else + { + throw new Exception($"Unexpected btree_u32 delete data {deleted.Data}"); + } + }; + + test.Db.Reducers.InsertIntoBtreeU32(new() { new BTreeU32(0, 0) }); + test.Db.Reducers.InsertIntoPkBtreeU32(new() { new PkU32(0, 0) }, new() { new BTreeU32(0, 1) }); + test.Db.Reducers.DeleteFromBtreeU32(new() { new BTreeU32(0, 0) }); + test.Db.Reducers.DeleteFromBtreeU32(new() { new BTreeU32(0, 1) }); + + test.FrameTickUntil(() => firstBtreeDeleteReducerSeen && secondBtreeDeleteReducerSeen && pkDeletes == 1); + Require(pkInserts == 1, $"Expected one pk_u32 insert, got {pkInserts}"); +} + void RunTwoDifferentCompressionAlgos() { var bytes = Enumerable.Range(0, 1 << 15).Select(i => (byte)(i % 251)).ToList(); @@ -971,6 +1075,27 @@ void RunRlsSubscription() FrameTickUntil(new[] { alice, bob }, () => aliceInserted && bobInserted); } +void RunPkSimpleEnum() +{ + using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkSimpleEnum())); + var updated = false; + var enumValue = SimpleEnum.Two; + test.Db.Db.PkSimpleEnum.OnInsert += (_, row) => + { + Require(row.A == enumValue && row.Data == 42, "Unexpected pk_simple_enum insert"); + test.Db.Reducers.UpdatePkSimpleEnum(enumValue, 24); + }; + test.Db.Db.PkSimpleEnum.OnUpdate += (_, oldRow, newRow) => + { + Require(oldRow.A == enumValue && oldRow.Data == 42, "Unexpected old pk_simple_enum row"); + Require(newRow.A == enumValue && newRow.Data == 24, "Unexpected new pk_simple_enum row"); + updated = true; + }; + test.Db.Db.PkSimpleEnum.OnDelete += (_, _) => throw new Exception("pk_simple_enum should not be deleted"); + test.Db.Reducers.InsertPkSimpleEnum(enumValue, 42); + test.FrameTickUntil(() => updated); +} + void RunIndexedSimpleEnum() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.IndexedSimpleEnum())); @@ -1097,6 +1222,26 @@ HarnessConnection ConnectAndSubscribeSql(params string[] queries) return harness; } +HarnessConnection ConnectAndSubscribeAll() +{ + var connected = false; + var applied = false; + DbConnection db = null!; + db = BuildConnection() + .OnConnect((conn, _, _) => + { + conn.SubscriptionBuilder() + .OnApplied(_ => applied = true) + .OnError((_, err) => throw err) + .SubscribeToAllTables(); + connected = true; + }) + .Build(); + var harness = new HarnessConnection(db); + harness.FrameTickUntil(() => connected && applied); + return harness; +} + HarnessConnection ConnectAndSubscribeCompression(Compression compression, List expected) { var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.VecU8()), compression); diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs index 1e64fb908b3..78ebc2c0632 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -34,6 +34,12 @@ fn subscription_error_smoke_test() { make_test("subscription-error-smoke-test").run(); } +#[test] +#[serial(CsharpSdk)] +fn subscribe_all_select_star() { + make_test("subscribe-all-select-star").run(); +} + #[test] #[serial(CsharpSdk)] fn delete_primitive() { @@ -257,6 +263,12 @@ fn test_lhs_join_update_disjoint_queries() { make_test("test-lhs-join-update-disjoint-queries").run(); } +#[test] +#[serial(CsharpSdk)] +fn test_intra_query_bag_semantics_for_join() { + make_test("test-intra-query-bag-semantics-for-join").run(); +} + #[test] #[serial(CsharpSdk)] fn two_different_compression_algos() { @@ -275,6 +287,12 @@ fn test_rls_subscription() { make_test("test-rls-subscription").run(); } +#[test] +#[serial(CsharpSdk)] +fn pk_simple_enum() { + make_test("pk-simple-enum").run(); +} + #[test] #[serial(CsharpSdk)] fn indexed_simple_enum() { From aa0fe9c5f15d8ba1b935df4cde906ca49bca9be4 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 11:33:31 -0500 Subject: [PATCH 04/18] Fix bug when unsubscribing from pending subscriptions and add test --- sdks/csharp/src/Event.cs | 15 +++++++++---- sdks/csharp/tests/sdk-test-client/Program.cs | 23 ++++++++++++++++++++ sdks/csharp/tests/sdk_csharp.rs | 6 +++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/sdks/csharp/src/Event.cs b/sdks/csharp/src/Event.cs index 767265b4224..ad89faddd02 100644 --- a/sdks/csharp/src/Event.cs +++ b/sdks/csharp/src/Event.cs @@ -177,6 +177,7 @@ public class SubscriptionHandleBase : IS private Action? onEnded; private QuerySetId? queryId; + private bool unsubscribeCalled; private SubscriptionState state; @@ -205,6 +206,11 @@ public bool IsActive void ISubscriptionHandle.OnApplied(ISubscriptionEventContext ctx) { state = new SubscriptionState.Active(queryId ?? throw new InvalidOperationException("Subscription query id is missing.")); + if (unsubscribeCalled) + { + conn.Unsubscribe(queryId); + return; + } onApplied?.Invoke((SubscriptionEventContext)ctx); } @@ -257,11 +263,11 @@ public void Unsubscribe() /// public void UnsubscribeThen(Action? onEnded) { - if (state is not SubscriptionState.Active) + if (state is SubscriptionState.Ended) { - throw new Exception("Cannot unsubscribe from inactive subscription."); + throw new Exception("Cannot unsubscribe from ended subscription."); } - if (this.onEnded != null) + if (unsubscribeCalled) { throw new Exception("Unsubscribe already called."); } @@ -271,11 +277,12 @@ public void UnsubscribeThen(Action? onEnded) onEnded = (ctx) => { }; } this.onEnded = onEnded; + unsubscribeCalled = true; if (queryId == null) { Log.Warn("Unsubscribing from a query that was never submitted to the server does nothing."); } - else + else if (state is SubscriptionState.Active) { conn.Unsubscribe(queryId); } diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index f2e14b36313..aedfe0b9beb 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -24,6 +24,9 @@ case "insert-primitive": RunInsertPrimitive(); break; + case "subscribe-and-cancel": + RunSubscribeAndCancel(); + break; case "subscribe-and-unsubscribe": RunSubscribeAndUnsubscribe(); break; @@ -218,6 +221,26 @@ void RunInsertPrimitive() test.FrameTickUntil(() => remaining == 0); } +void RunSubscribeAndCancel() +{ + using var test = Connect(); + var ended = false; + var handle = test.Db.SubscriptionBuilder() + .OnApplied(_ => throw new Exception("Subscription should never be applied")) + .OnError((_, err) => throw err) + .Subscribe(new[] { "SELECT * FROM one_u8" }); + + Require(!handle.IsActive, "New subscription should not be active yet"); + Require(!handle.IsEnded, "New subscription should not be ended yet"); + handle.UnsubscribeThen(_ => + { + Require(!handle.IsActive, "Canceled subscription should not be active"); + Require(handle.IsEnded, "Canceled subscription should be ended"); + ended = true; + }); + test.FrameTickUntil(() => ended); +} + void RunSubscribeAndUnsubscribe() { using var test = Connect(); diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs index 78ebc2c0632..2c51d8074c4 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -22,6 +22,12 @@ fn insert_primitive() { make_test("insert-primitive").run(); } +#[test] +#[serial(CsharpSdk)] +fn subscribe_and_cancel() { + make_test("subscribe-and-cancel").run(); +} + #[test] #[serial(CsharpSdk)] fn subscribe_and_unsubscribe() { From e2914c85eda3b732bebaef72feca1a2fa2f204b6 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 11:52:51 -0500 Subject: [PATCH 05/18] Expand coverage --- .../procedural-view-pk-client/Program.cs | 219 ++++++++++++++ .../procedural-view-pk-client.csproj | 18 ++ sdks/csharp/tests/procedure-client/Program.cs | 272 ++++++++++++++++++ .../procedure-client/procedure-client.csproj | 18 ++ sdks/csharp/tests/sdk_csharp.rs | 120 ++++++++ sdks/csharp/tests/view-pk-client/Program.cs | 173 +++++++++++ .../view-pk-client/view-pk-client.csproj | 18 ++ 7 files changed, 838 insertions(+) create mode 100644 sdks/csharp/tests/procedural-view-pk-client/Program.cs create mode 100644 sdks/csharp/tests/procedural-view-pk-client/procedural-view-pk-client.csproj create mode 100644 sdks/csharp/tests/procedure-client/Program.cs create mode 100644 sdks/csharp/tests/procedure-client/procedure-client.csproj create mode 100644 sdks/csharp/tests/view-pk-client/Program.cs create mode 100644 sdks/csharp/tests/view-pk-client/view-pk-client.csproj diff --git a/sdks/csharp/tests/procedural-view-pk-client/Program.cs b/sdks/csharp/tests/procedural-view-pk-client/Program.cs new file mode 100644 index 00000000000..ad8f7a84694 --- /dev/null +++ b/sdks/csharp/tests/procedural-view-pk-client/Program.cs @@ -0,0 +1,219 @@ +using System; +using SpacetimeDB; +using SpacetimeDB.Types; + +const string DbNameEnvVar = "SPACETIME_SDK_TEST_DB_NAME"; +const string ServerUrlEnvVar = "SPACETIME_SDK_TEST_SERVER_URL"; + +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +{ + Console.Error.WriteLine(eventArgs.ExceptionObject); + Environment.Exit(1); +}; + +var testName = args.Length > 0 ? args[0] : throw new ArgumentException("Pass a test name as argv[1]"); +var dbName = Environment.GetEnvironmentVariable(DbNameEnvVar) ?? throw new InvalidOperationException($"{DbNameEnvVar} is not set"); +var serverUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar) ?? "http://localhost:3000"; + +switch (testName) +{ + case "sender-scoped-pk-view": + RunSenderScopedPkView(); + break; + case "view-pk-left-semijoin": + RunViewPkLeftSemijoin(); + break; + case "view-pk-right-semijoin": + RunViewPkRightSemijoin(); + break; + default: + throw new ArgumentException($"Unknown C# procedural-view-pk harness test: {testName}"); +} + +void RunSenderScopedPkView() +{ + using var senderA = Connect(); + using var senderB = Connect(); + var senderAUpdated = false; + var senderBUpdated = false; + + SubscribeThen(senderA, builder => builder.AddQuery(qb => qb.From.SenderLeftView()), ctx => + { + ctx.Db.SenderLeftView.OnUpdate += (_, oldRow, newRow) => + { + RequireLeft(oldRow, 1, 10); + RequireLeft(newRow, 1, 11); + senderAUpdated = true; + }; + ctx.Reducers.InsertLeft(1, 10); + ctx.Reducers.UpdateLeft(1, 11); + }); + + SubscribeThen(senderB, builder => builder.AddQuery(qb => qb.From.SenderLeftView()), ctx => + { + ctx.Db.SenderLeftView.OnUpdate += (_, oldRow, newRow) => + { + RequireLeft(oldRow, 2, 20); + RequireLeft(newRow, 2, 21); + senderBUpdated = true; + }; + ctx.Reducers.InsertLeft(2, 20); + ctx.Reducers.UpdateLeft(2, 21); + }); + + FrameTickUntil(() => senderAUpdated && senderBUpdated, senderA, senderB); +} + +void RunViewPkLeftSemijoin() +{ + using var test = Connect(); + var inserted = false; + + SubscribeThen(test, builder => builder.AddQuery(qb => qb.From.SenderRightView() + .Filter(right => right.Filter.Eq(300UL)) + .RightSemijoin(qb.From.SenderLeftView(), (right, left) => right.Id.Eq(left.Id)) + .Filter(left => left.Filter.Eq(100UL))), ctx => + { + ctx.Db.SenderLeftView.OnInsert += (eventCtx, row) => + { + Require(eventCtx.Db.SenderLeftView.Count == 1, $"Expected one left view row, got {eventCtx.Db.SenderLeftView.Count}"); + RequireLeft(row, 10, 100); + inserted = true; + }; + InsertSemijoinSourceRows(ctx); + }); + + test.FrameTickUntil(() => inserted); +} + +void RunViewPkRightSemijoin() +{ + using var test = Connect(); + var inserted = false; + + SubscribeThen(test, builder => builder.AddQuery(qb => qb.From.SenderLeftView() + .Filter(left => left.Filter.Eq(100UL)) + .RightSemijoin(qb.From.SenderRightView(), (left, right) => left.Id.Eq(right.Id)) + .Filter(right => right.Filter.Eq(300UL))), ctx => + { + ctx.Db.SenderRightView.OnInsert += (eventCtx, row) => + { + Require(eventCtx.Db.SenderRightView.Count == 1, $"Expected one right view row, got {eventCtx.Db.SenderRightView.Count}"); + RequireRight(row, 10, 300); + inserted = true; + }; + InsertSemijoinSourceRows(ctx); + }); + + test.FrameTickUntil(() => inserted); +} + +void InsertSemijoinSourceRows(SubscriptionEventContext ctx) +{ + ctx.Reducers.InsertLeft(10, 100); + ctx.Reducers.InsertLeft(20, 200); + ctx.Reducers.InsertRight(10, 300); + ctx.Reducers.InsertRight(20, 400); +} + +void SubscribeThen(TestConnection test, Func build, Action onApplied) +{ + var applied = false; + build(test.Db.SubscriptionBuilder()) + .OnApplied(ctx => + { + applied = true; + onApplied(ctx); + }) + .OnError((_, err) => throw err) + .Subscribe(); + test.FrameTickUntil(() => applied); +} + +void RequireLeft(LeftSource row, ulong id, ulong filter) +{ + Require(row.Id == id, $"Expected left id {id}, got {row.Id}"); + Require(row.Filter == filter, $"Expected left filter {filter}, got {row.Filter}"); +} + +void RequireRight(RightSource row, ulong id, ulong filter) +{ + Require(row.Id == id, $"Expected right id {id}, got {row.Id}"); + Require(row.Filter == filter, $"Expected right filter {filter}, got {row.Filter}"); +} + +TestConnection Connect() +{ + var connected = false; + var disconnected = false; + var conn = DbConnection.Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .OnConnect((_, _, _) => connected = true) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + disconnected = true; + throw new Exception("Unexpected disconnect", err); + }) + .Build(); + + var test = new TestConnection(conn, () => disconnected); + test.FrameTickUntil(() => connected); + return test; +} + +void FrameTickUntil(Func predicate, params TestConnection[] connections) +{ + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); + while (!predicate()) + { + if (DateTime.UtcNow >= deadline) + { + throw new Exception("Timed out waiting for test condition"); + } + foreach (var connection in connections) + { + if (connection.Disconnected()) + { + throw new Exception("Connection disconnected before test completed"); + } + connection.Db.FrameTick(); + } + Thread.Sleep(1); + } +} + +void Require(bool condition, string message) +{ + if (!condition) + { + throw new Exception(message); + } +} + +sealed class TestConnection(DbConnection db, Func disconnected) : IDisposable +{ + public DbConnection Db { get; } = db; + public bool Disconnected() => disconnected(); + + public void FrameTickUntil(Func predicate) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); + while (!predicate()) + { + if (DateTime.UtcNow >= deadline) + { + throw new Exception("Timed out waiting for test condition"); + } + if (disconnected()) + { + throw new Exception("Connection disconnected before test completed"); + } + Db.FrameTick(); + Thread.Sleep(1); + } + } + + public void Dispose() => Db.Disconnect(); +} diff --git a/sdks/csharp/tests/procedural-view-pk-client/procedural-view-pk-client.csproj b/sdks/csharp/tests/procedural-view-pk-client/procedural-view-pk-client.csproj new file mode 100644 index 00000000000..9f5de8e479f --- /dev/null +++ b/sdks/csharp/tests/procedural-view-pk-client/procedural-view-pk-client.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + ../../../../crates/bindings-csharp/BSATN.Runtime/bin/Release/net8.0/SpacetimeDB.BSATN.Runtime.dll + + + + + diff --git a/sdks/csharp/tests/procedure-client/Program.cs b/sdks/csharp/tests/procedure-client/Program.cs new file mode 100644 index 00000000000..8fba9cfe278 --- /dev/null +++ b/sdks/csharp/tests/procedure-client/Program.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using SpacetimeDB; +using SpacetimeDB.Types; + +const string DbNameEnvVar = "SPACETIME_SDK_TEST_DB_NAME"; +const string ServerUrlEnvVar = "SPACETIME_SDK_TEST_SERVER_URL"; + +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +{ + Console.Error.WriteLine(eventArgs.ExceptionObject); + Environment.Exit(1); +}; + +var testName = args.Length > 0 ? args[0] : throw new ArgumentException("Pass a test name as argv[1]"); +var dbName = Environment.GetEnvironmentVariable(DbNameEnvVar) ?? throw new InvalidOperationException($"{DbNameEnvVar} is not set"); +var serverUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar) ?? "http://localhost:3000"; + +switch (testName) +{ + case "procedure-return-values": + RunProcedureReturnValues(); + break; + case "procedure-observe-panic": + RunProcedureObservePanic(); + break; + case "insert-with-tx-commit": + RunInsertWithTxCommit(); + break; + case "insert-with-tx-rollback": + RunInsertWithTxRollback(); + break; + case "procedure-http-ok": + RunProcedureHttpOk(); + break; + case "procedure-http-err": + RunProcedureHttpErr(); + break; + case "schedule-procedure": + RunScheduleProcedure(); + break; + default: + throw new ArgumentException($"Unknown C# procedure harness test: {testName}"); +} + +void RunProcedureReturnValues() +{ + using var test = Connect(); + var remaining = 4; + void Seen() => remaining--; + + test.Db.Procedures.ReturnPrimitive(1, 2, (_, result) => + { + RequireSuccess(result); + Require(result.Value == 3, $"Expected return_primitive to return 3, got {result.Value}"); + Seen(); + }); + test.Db.Procedures.ReturnStruct(1234, "foo", (_, result) => + { + RequireSuccess(result); + Require(result.Value.A == 1234 && result.Value.B == "foo", "Unexpected return_struct result"); + Seen(); + }); + test.Db.Procedures.ReturnEnumA(1234, (_, result) => + { + RequireSuccess(result); + Require(result.Value is ReturnEnum.A a && a.A_ == 1234, "Unexpected return_enum_a result"); + Seen(); + }); + test.Db.Procedures.ReturnEnumB("foo", (_, result) => + { + RequireSuccess(result); + Require(result.Value is ReturnEnum.B b && b.B_ == "foo", "Unexpected return_enum_b result"); + Seen(); + }); + + test.FrameTickUntil(() => remaining == 0); +} + +void RunProcedureObservePanic() +{ + using var test = Connect(); + var observed = false; + + test.Db.Procedures.WillPanic((_, result) => + { + Require(!result.IsSuccess, "Expected will_panic to fail"); + observed = true; + }); + + test.FrameTickUntil(() => observed); +} + +void RunInsertWithTxCommit() +{ + using var test = ConnectAndSubscribeAll(); + Require(test.Db.Db.MyTable.Count == 0, "Expected my_table to start empty"); + + var inserted = false; + var callback = false; + test.Db.Db.MyTable.OnInsert += (_, row) => + { + RequireExpectedReturnStruct(row.Field); + inserted = true; + }; + + test.Db.Procedures.InsertWithTxCommit((_, result) => + { + RequireSuccess(result); + Require(test.Db.Db.MyTable.Count == 1, $"Expected one my_table row, got {test.Db.Db.MyTable.Count}"); + RequireExpectedReturnStruct(test.Db.Db.MyTable.Iter().First().Field); + callback = true; + }); + + test.FrameTickUntil(() => inserted && callback); +} + +void RunInsertWithTxRollback() +{ + using var test = ConnectAndSubscribeAll(); + Require(test.Db.Db.MyTable.Count == 0, "Expected my_table to start empty"); + + test.Db.Db.MyTable.OnInsert += (_, _) => throw new Exception("Rollback procedure unexpectedly inserted a row"); + + var callback = false; + test.Db.Procedures.InsertWithTxRollback((_, result) => + { + RequireSuccess(result); + Require(test.Db.Db.MyTable.Count == 0, $"Expected no my_table rows, got {test.Db.Db.MyTable.Count}"); + callback = true; + }); + + test.FrameTickUntil(() => callback); +} + +void RunProcedureHttpOk() +{ + using var test = Connect(); + var observed = false; + + test.Db.Procedures.ReadMySchema(serverUrl, (_, result) => + { + RequireSuccess(result); + Require(result.Value.Contains("\"read_my_schema\""), "Schema response did not include read_my_schema"); + observed = true; + }); + + test.FrameTickUntil(() => observed); +} + +void RunProcedureHttpErr() +{ + using var test = Connect(); + var observed = false; + + test.Db.Procedures.InvalidRequest((_, result) => + { + RequireSuccess(result); + Require(result.Value.Contains("error"), "Expected invalid_request result to mention an error"); + Require(result.Value.Contains("http://foo.invalid/"), "Expected invalid_request result to mention the URL"); + observed = true; + }); + + test.FrameTickUntil(() => observed); +} + +void RunScheduleProcedure() +{ + using var test = ConnectAndSubscribeAll(); + Require(test.Db.Db.ProcInsertsInto.Count == 0, "Expected proc_inserts_into to start empty"); + + var inserted = false; + test.Db.Db.ProcInsertsInto.OnInsert += (_, row) => + { + Require(row.X == 42, $"Expected X=42, got {row.X}"); + Require(row.Y == 24, $"Expected Y=24, got {row.Y}"); + var elapsed = row.ProcedureTs.TimeDurationSince(row.ReducerTs); + Require(elapsed.Microseconds >= 1_000_000, $"Procedure ran too soon: {elapsed.Microseconds}us"); + Require(elapsed.Microseconds <= 2_000_000, $"Procedure ran too late: {elapsed.Microseconds}us"); + inserted = true; + }; + + test.Db.Reducers.ScheduleProc(); + test.FrameTickUntil(() => inserted); +} + +void RequireExpectedReturnStruct(ReturnStruct value) +{ + Require(value.A == 42, $"Expected A=42, got {value.A}"); + Require(value.B == "magic", $"Expected B=magic, got {value.B}"); +} + +TestConnection ConnectAndSubscribeAll() +{ + var test = Connect(); + var applied = false; + test.Db.SubscriptionBuilder() + .OnApplied(_ => applied = true) + .OnError((_, err) => throw err) + .Subscribe(new[] + { + "SELECT * FROM my_table", + "SELECT * FROM proc_inserts_into", + "SELECT * FROM pk_uuid", + }); + test.FrameTickUntil(() => applied); + return test; +} + +TestConnection Connect() +{ + var connected = false; + var disconnected = false; + DbConnection? conn = null; + + conn = DbConnection.Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .OnConnect((_, _, _) => connected = true) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + disconnected = true; + throw new Exception("Unexpected disconnect", err); + }) + .Build(); + + var test = new TestConnection(conn, () => disconnected); + test.FrameTickUntil(() => connected); + return test; +} + +void RequireSuccess(ProcedureCallbackResult result) +{ + if (!result.IsSuccess) + { + throw new Exception("Procedure failed", result.Error); + } +} + +void Require(bool condition, string message) +{ + if (!condition) + { + throw new Exception(message); + } +} + +sealed class TestConnection(DbConnection db, Func disconnected) : IDisposable +{ + public DbConnection Db { get; } = db; + + public void FrameTickUntil(Func predicate) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); + while (!predicate()) + { + if (DateTime.UtcNow >= deadline) + { + throw new Exception("Timed out waiting for test condition"); + } + if (disconnected()) + { + throw new Exception("Connection disconnected before test completed"); + } + Db.FrameTick(); + Thread.Sleep(1); + } + } + + public void Dispose() => Db.Disconnect(); +} diff --git a/sdks/csharp/tests/procedure-client/procedure-client.csproj b/sdks/csharp/tests/procedure-client/procedure-client.csproj new file mode 100644 index 00000000000..9f5de8e479f --- /dev/null +++ b/sdks/csharp/tests/procedure-client/procedure-client.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + ../../../../crates/bindings-csharp/BSATN.Runtime/bin/Release/net8.0/SpacetimeDB.BSATN.Runtime.dll + + + + + diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs index 2c51d8074c4..e4e8d0648ec 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -3,6 +3,9 @@ use spacetimedb_testing::sdk::Test; const TEST_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/sdk-test-client"); const CONNECT_DISCONNECT_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/connect-disconnect-client"); +const PROCEDURE_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/procedure-client"); +const VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/view-pk-client"); +const PROCEDURAL_VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/procedural-view-pk-client"); fn make_test(subcommand: &str) -> Test { Test::builder() @@ -16,6 +19,45 @@ fn make_test(subcommand: &str) -> Test { .build() } +fn make_procedure_test(subcommand: &str) -> Test { + Test::builder() + .with_name(format!("csharp-client-{subcommand}")) + .with_module("sdk-test-procedure-cs") + .with_client(PROCEDURE_CLIENT) + .with_language("csharp") + .with_generate_private_items(true) + .with_bindings_dir("module_bindings") + .with_compile_command("bash ../build-client.sh") + .with_run_command(format!("dotnet ./bin~/Debug/net8.0/procedure-client.dll {subcommand}")) + .build() +} + +fn make_view_pk_test(subcommand: &str) -> Test { + Test::builder() + .with_name(format!("csharp-client-{subcommand}")) + .with_module("sdk-test-view-pk-cs") + .with_client(VIEW_PK_CLIENT) + .with_language("csharp") + .with_bindings_dir("module_bindings") + .with_compile_command("bash ../build-client.sh") + .with_run_command(format!("dotnet ./bin~/Debug/net8.0/view-pk-client.dll {subcommand}")) + .build() +} + +fn make_procedural_view_pk_test(subcommand: &str) -> Test { + Test::builder() + .with_name(format!("csharp-client-{subcommand}")) + .with_module("sdk-test-procedural-view-pk-cs") + .with_client(PROCEDURAL_VIEW_PK_CLIENT) + .with_language("csharp") + .with_bindings_dir("module_bindings") + .with_compile_command("bash ../build-client.sh") + .with_run_command(format!( + "dotnet ./bin~/Debug/net8.0/procedural-view-pk-client.dll {subcommand}" + )) + .build() +} + #[test] #[serial(CsharpSdk)] fn insert_primitive() { @@ -317,6 +359,84 @@ fn sorted_uuids_insert() { make_test("sorted-uuids-insert").run(); } +#[test] +#[serial(CsharpSdk)] +fn procedure_return_values() { + make_procedure_test("procedure-return-values").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn procedure_observe_panic() { + make_procedure_test("procedure-observe-panic").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_with_tx_commit() { + make_procedure_test("insert-with-tx-commit").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn insert_with_tx_rollback() { + make_procedure_test("insert-with-tx-rollback").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn procedure_http_ok() { + make_procedure_test("procedure-http-ok").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn procedure_http_err() { + make_procedure_test("procedure-http-err").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn schedule_procedure() { + make_procedure_test("schedule-procedure").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn view_pk_on_update() { + make_view_pk_test("view-pk-on-update").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn view_pk_join_query_builder() { + make_view_pk_test("view-pk-join-query-builder").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn view_pk_semijoin_two_sender_views_query_builder() { + make_view_pk_test("view-pk-semijoin-two-sender-views-query-builder").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn sender_scoped_procedural_pk_view() { + make_procedural_view_pk_test("sender-scoped-pk-view").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn procedural_view_pk_left_semijoin() { + make_procedural_view_pk_test("view-pk-left-semijoin").run(); +} + +#[test] +#[serial(CsharpSdk)] +fn procedural_view_pk_right_semijoin() { + make_procedural_view_pk_test("view-pk-right-semijoin").run(); +} + #[test] #[serial(CsharpSdk)] fn connect_disconnect_callbacks() { diff --git a/sdks/csharp/tests/view-pk-client/Program.cs b/sdks/csharp/tests/view-pk-client/Program.cs new file mode 100644 index 00000000000..6bc09e60fe1 --- /dev/null +++ b/sdks/csharp/tests/view-pk-client/Program.cs @@ -0,0 +1,173 @@ +using System; +using SpacetimeDB; +using SpacetimeDB.Types; + +const string DbNameEnvVar = "SPACETIME_SDK_TEST_DB_NAME"; +const string ServerUrlEnvVar = "SPACETIME_SDK_TEST_SERVER_URL"; + +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +{ + Console.Error.WriteLine(eventArgs.ExceptionObject); + Environment.Exit(1); +}; + +var testName = args.Length > 0 ? args[0] : throw new ArgumentException("Pass a test name as argv[1]"); +var dbName = Environment.GetEnvironmentVariable(DbNameEnvVar) ?? throw new InvalidOperationException($"{DbNameEnvVar} is not set"); +var serverUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar) ?? "http://localhost:3000"; + +switch (testName) +{ + case "view-pk-on-update": + RunViewPkOnUpdate(); + break; + case "view-pk-join-query-builder": + RunViewPkJoinQueryBuilder(); + break; + case "view-pk-semijoin-two-sender-views-query-builder": + RunViewPkSemijoinTwoSenderViewsQueryBuilder(); + break; + default: + throw new ArgumentException($"Unknown C# view-pk harness test: {testName}"); +} + +void RunViewPkOnUpdate() +{ + using var test = Connect(); + var updated = false; + + SubscribeThen(test, builder => builder.AddQuery(qb => qb.From.AllViewPkPlayers()), ctx => + { + ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + { + RequirePlayer(oldRow, 1, "before"); + RequirePlayer(newRow, 1, "after"); + updated = true; + }; + + ctx.Reducers.InsertViewPkPlayer(1, "before"); + ctx.Reducers.UpdateViewPkPlayer(1, "after"); + }); + + test.FrameTickUntil(() => updated); +} + +void RunViewPkJoinQueryBuilder() +{ + using var test = Connect(); + var updated = false; + + SubscribeThen(test, builder => builder.AddQuery(qb => qb.From.ViewPkMembership() + .RightSemijoin(qb.From.AllViewPkPlayers(), (membership, player) => membership.PlayerId.Eq(player.Id))), ctx => + { + ctx.Db.AllViewPkPlayers.OnUpdate += (_, oldRow, newRow) => + { + RequirePlayer(oldRow, 1, "before"); + RequirePlayer(newRow, 1, "after"); + updated = true; + }; + + ctx.Reducers.InsertViewPkPlayer(1, "before"); + ctx.Reducers.InsertViewPkMembership(1, 1); + ctx.Reducers.UpdateViewPkPlayer(1, "after"); + }); + + test.FrameTickUntil(() => updated); +} + +void RunViewPkSemijoinTwoSenderViewsQueryBuilder() +{ + using var test = Connect(); + var updated = false; + + SubscribeThen(test, builder => builder.AddQuery(qb => qb.From.SenderViewPkPlayersA() + .RightSemijoin(qb.From.SenderViewPkPlayersB(), (left, right) => left.Id.Eq(right.Id))), ctx => + { + ctx.Db.SenderViewPkPlayersB.OnUpdate += (_, oldRow, newRow) => + { + RequirePlayer(oldRow, 1, "before"); + RequirePlayer(newRow, 1, "after"); + updated = true; + }; + + ctx.Reducers.InsertViewPkPlayer(1, "before"); + ctx.Reducers.InsertViewPkMembership(1, 1); + ctx.Reducers.InsertViewPkMembershipSecondary(1, 1); + ctx.Reducers.UpdateViewPkPlayer(1, "after"); + }); + + test.FrameTickUntil(() => updated); +} + +void SubscribeThen(TestConnection test, Func build, Action onApplied) +{ + var applied = false; + build(test.Db.SubscriptionBuilder()) + .OnApplied(ctx => + { + applied = true; + onApplied(ctx); + }) + .OnError((_, err) => throw err) + .Subscribe(); + test.FrameTickUntil(() => applied); +} + +void RequirePlayer(ViewPkPlayer row, ulong id, string name) +{ + Require(row.Id == id, $"Expected player id {id}, got {row.Id}"); + Require(row.Name == name, $"Expected player name {name}, got {row.Name}"); +} + +TestConnection Connect() +{ + var connected = false; + var disconnected = false; + var conn = DbConnection.Builder() + .WithUri(serverUrl) + .WithDatabaseName(dbName) + .OnConnect((_, _, _) => connected = true) + .OnConnectError(err => throw err) + .OnDisconnect((_, err) => + { + disconnected = true; + throw new Exception("Unexpected disconnect", err); + }) + .Build(); + + var test = new TestConnection(conn, () => disconnected); + test.FrameTickUntil(() => connected); + return test; +} + +void Require(bool condition, string message) +{ + if (!condition) + { + throw new Exception(message); + } +} + +sealed class TestConnection(DbConnection db, Func disconnected) : IDisposable +{ + public DbConnection Db { get; } = db; + + public void FrameTickUntil(Func predicate) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); + while (!predicate()) + { + if (DateTime.UtcNow >= deadline) + { + throw new Exception("Timed out waiting for test condition"); + } + if (disconnected()) + { + throw new Exception("Connection disconnected before test completed"); + } + Db.FrameTick(); + Thread.Sleep(1); + } + } + + public void Dispose() => Db.Disconnect(); +} diff --git a/sdks/csharp/tests/view-pk-client/view-pk-client.csproj b/sdks/csharp/tests/view-pk-client/view-pk-client.csproj new file mode 100644 index 00000000000..9f5de8e479f --- /dev/null +++ b/sdks/csharp/tests/view-pk-client/view-pk-client.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + ../../../../crates/bindings-csharp/BSATN.Runtime/bin/Release/net8.0/SpacetimeDB.BSATN.Runtime.dll + + + + + From 69630b2813969335cf77fa72b83a7c128948795a Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 12:34:05 -0500 Subject: [PATCH 06/18] Improve procedure-http-ok to parse JSON and verify the procedure export --- sdks/csharp/tests/procedure-client/Program.cs | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/sdks/csharp/tests/procedure-client/Program.cs b/sdks/csharp/tests/procedure-client/Program.cs index 8fba9cfe278..51d05825681 100644 --- a/sdks/csharp/tests/procedure-client/Program.cs +++ b/sdks/csharp/tests/procedure-client/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json; using SpacetimeDB; using SpacetimeDB.Types; @@ -141,7 +142,9 @@ void RunProcedureHttpOk() test.Db.Procedures.ReadMySchema(serverUrl, (_, result) => { RequireSuccess(result); - Require(result.Value.Contains("\"read_my_schema\""), "Schema response did not include read_my_schema"); + Require( + SchemaContainsProcedureExport(result.Value, "read_my_schema"), + "Schema response did not include a procedure export named read_my_schema"); observed = true; }); @@ -190,6 +193,64 @@ void RequireExpectedReturnStruct(ReturnStruct value) Require(value.B == "magic", $"Expected B=magic, got {value.B}"); } +bool SchemaContainsProcedureExport(string schemaJson, string procedureName) +{ + using var document = JsonDocument.Parse(schemaJson); + Require(document.RootElement.ValueKind == JsonValueKind.Object, "Schema response was not a JSON object"); + Require(document.RootElement.TryGetProperty("misc_exports", out var miscExports), "Schema response did not contain misc_exports"); + Require(miscExports.ValueKind == JsonValueKind.Array, "Schema misc_exports was not an array"); + + foreach (var export in miscExports.EnumerateArray()) + { + if (TryGetProcedureDef(export, out var procedureDef) && ProcedureNameEquals(procedureDef, procedureName)) + { + return true; + } + } + + return false; +} + +bool TryGetProcedureDef(JsonElement export, out JsonElement procedureDef) +{ + if (export.ValueKind == JsonValueKind.Object) + { + if (export.TryGetProperty("Procedure", out procedureDef) || export.TryGetProperty("procedure", out procedureDef)) + { + return procedureDef.ValueKind == JsonValueKind.Object; + } + + if (export.TryGetProperty("tag", out var tag) + && tag.ValueKind == JsonValueKind.String + && string.Equals(tag.GetString(), "Procedure", StringComparison.OrdinalIgnoreCase) + && export.TryGetProperty("value", out procedureDef)) + { + return procedureDef.ValueKind == JsonValueKind.Object; + } + } + + procedureDef = default; + return false; +} + +bool ProcedureNameEquals(JsonElement procedureDef, string procedureName) +{ + return procedureDef.TryGetProperty("name", out var name) && IdentifierEquals(name, procedureName); +} + +bool IdentifierEquals(JsonElement identifier, string expected) +{ + if (identifier.ValueKind == JsonValueKind.String) + { + return identifier.GetString() == expected; + } + + return identifier.ValueKind == JsonValueKind.Object + && identifier.TryGetProperty("name", out var name) + && name.ValueKind == JsonValueKind.String + && name.GetString() == expected; +} + TestConnection ConnectAndSubscribeAll() { var test = Connect(); From 198cc0c440ffbf697069464ce39f621e4d0a74ff Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 12:57:45 -0500 Subject: [PATCH 07/18] Expand core type tests --- sdks/csharp/tests/sdk-test-client/Program.cs | 508 +++++++++++++++---- 1 file changed, 419 insertions(+), 89 deletions(-) diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index aedfe0b9beb..36e7085c48f 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -176,31 +176,27 @@ void RunInsertPrimitive() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() - .AddQuery(qb => qb.From.OneU8()) - .AddQuery(qb => qb.From.OneU16()) - .AddQuery(qb => qb.From.OneU32()) - .AddQuery(qb => qb.From.OneU64()) - .AddQuery(qb => qb.From.OneI8()) - .AddQuery(qb => qb.From.OneI16()) - .AddQuery(qb => qb.From.OneI32()) - .AddQuery(qb => qb.From.OneI64()) - .AddQuery(qb => qb.From.OneBool()) - .AddQuery(qb => qb.From.OneF32()) - .AddQuery(qb => qb.From.OneF64()) - .AddQuery(qb => qb.From.OneString())); - - var remaining = 12; + using var test = ConnectAndSubscribeAll(); + var u128 = new U128(0, 5); + var u256 = new U256(new U128(0, 0), new U128(0, 6)); + var i128 = new I128(0, 7); + var i256 = new I256(new U128(0, 0), new U128(0, 8)); + + var remaining = 16; void Seen() => remaining--; test.Db.Db.OneU8.OnInsert += (_, row) => { Require(row.N == 1, "OneU8 did not round-trip"); Seen(); }; test.Db.Db.OneU16.OnInsert += (_, row) => { Require(row.N == 2, "OneU16 did not round-trip"); Seen(); }; test.Db.Db.OneU32.OnInsert += (_, row) => { Require(row.N == 3, "OneU32 did not round-trip"); Seen(); }; test.Db.Db.OneU64.OnInsert += (_, row) => { Require(row.N == 4, "OneU64 did not round-trip"); Seen(); }; + test.Db.Db.OneU128.OnInsert += (_, row) => { Require(row.N.Equals(u128), "OneU128 did not round-trip"); Seen(); }; + test.Db.Db.OneU256.OnInsert += (_, row) => { Require(row.N.Equals(u256), "OneU256 did not round-trip"); Seen(); }; test.Db.Db.OneI8.OnInsert += (_, row) => { Require(row.N == -1, "OneI8 did not round-trip"); Seen(); }; test.Db.Db.OneI16.OnInsert += (_, row) => { Require(row.N == -2, "OneI16 did not round-trip"); Seen(); }; test.Db.Db.OneI32.OnInsert += (_, row) => { Require(row.N == -3, "OneI32 did not round-trip"); Seen(); }; test.Db.Db.OneI64.OnInsert += (_, row) => { Require(row.N == -4, "OneI64 did not round-trip"); Seen(); }; + test.Db.Db.OneI128.OnInsert += (_, row) => { Require(row.N.Equals(i128), "OneI128 did not round-trip"); Seen(); }; + test.Db.Db.OneI256.OnInsert += (_, row) => { Require(row.N.Equals(i256), "OneI256 did not round-trip"); Seen(); }; test.Db.Db.OneBool.OnInsert += (_, row) => { Require(row.B, "OneBool did not round-trip"); Seen(); }; test.Db.Db.OneF32.OnInsert += (_, row) => { Require(Math.Abs(row.F - 1.25f) < 0.001f, "OneF32 did not round-trip"); Seen(); }; test.Db.Db.OneF64.OnInsert += (_, row) => { Require(Math.Abs(row.F - 2.5) < 0.001, "OneF64 did not round-trip"); Seen(); }; @@ -210,10 +206,14 @@ void RunInsertPrimitive() test.Db.Reducers.InsertOneU16(2); test.Db.Reducers.InsertOneU32(3); test.Db.Reducers.InsertOneU64(4); + test.Db.Reducers.InsertOneU128(u128); + test.Db.Reducers.InsertOneU256(u256); test.Db.Reducers.InsertOneI8(-1); test.Db.Reducers.InsertOneI16(-2); test.Db.Reducers.InsertOneI32(-3); test.Db.Reducers.InsertOneI64(-4); + test.Db.Reducers.InsertOneI128(i128); + test.Db.Reducers.InsertOneI256(i256); test.Db.Reducers.InsertOneBool(true); test.Db.Reducers.InsertOneF32(1.25f); test.Db.Reducers.InsertOneF64(2.5); @@ -331,32 +331,118 @@ void RunSubscribeAllSelectStar() void RunDeletePrimitive() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.UniqueU8())); - var inserted = false; - var deleted = false; - - test.Db.Db.UniqueU8.OnInsert += (_, row) => - { - Require(row.N == 7 && row.Data == 10, "Unexpected unique_u8 insert row"); - inserted = true; - }; - test.Db.Db.UniqueU8.OnDelete += (_, row) => - { - Require(row.N == 7 && row.Data == 10, "Unexpected unique_u8 delete row"); - deleted = true; - }; + using var test = ConnectAndSubscribeAll(); + var u128 = new U128(0, 5); + var u256 = new U256(new U128(0, 0), new U128(0, 6)); + var i128 = new I128(0, 7); + var i256 = new I256(new U128(0, 0), new U128(0, 8)); + var remaining = 14; + void Seen() => remaining--; - test.Db.Reducers.InsertUniqueU8(7, 10); - test.FrameTickUntil(() => inserted); - test.Db.Reducers.DeleteUniqueU8(7); - test.FrameTickUntil(() => deleted); - Require(test.Db.Db.UniqueU8.Count == 0, $"Expected unique_u8 cache count 0, got {test.Db.Db.UniqueU8.Count}"); + test.Db.Db.UniqueU8.OnDelete += (_, row) => { Require(row.N == 1 && row.Data == 10, "Unexpected unique_u8 delete row"); Seen(); }; + test.Db.Db.UniqueU16.OnDelete += (_, row) => { Require(row.N == 2 && row.Data == 10, "Unexpected unique_u16 delete row"); Seen(); }; + test.Db.Db.UniqueU32.OnDelete += (_, row) => { Require(row.N == 3 && row.Data == 10, "Unexpected unique_u32 delete row"); Seen(); }; + test.Db.Db.UniqueU64.OnDelete += (_, row) => { Require(row.N == 4 && row.Data == 10, "Unexpected unique_u64 delete row"); Seen(); }; + test.Db.Db.UniqueU128.OnDelete += (_, row) => { Require(row.N.Equals(u128) && row.Data == 10, "Unexpected unique_u128 delete row"); Seen(); }; + test.Db.Db.UniqueU256.OnDelete += (_, row) => { Require(row.N.Equals(u256) && row.Data == 10, "Unexpected unique_u256 delete row"); Seen(); }; + test.Db.Db.UniqueI8.OnDelete += (_, row) => { Require(row.N == -1 && row.Data == 10, "Unexpected unique_i8 delete row"); Seen(); }; + test.Db.Db.UniqueI16.OnDelete += (_, row) => { Require(row.N == -2 && row.Data == 10, "Unexpected unique_i16 delete row"); Seen(); }; + test.Db.Db.UniqueI32.OnDelete += (_, row) => { Require(row.N == -3 && row.Data == 10, "Unexpected unique_i32 delete row"); Seen(); }; + test.Db.Db.UniqueI64.OnDelete += (_, row) => { Require(row.N == -4 && row.Data == 10, "Unexpected unique_i64 delete row"); Seen(); }; + test.Db.Db.UniqueI128.OnDelete += (_, row) => { Require(row.N.Equals(i128) && row.Data == 10, "Unexpected unique_i128 delete row"); Seen(); }; + test.Db.Db.UniqueI256.OnDelete += (_, row) => { Require(row.N.Equals(i256) && row.Data == 10, "Unexpected unique_i256 delete row"); Seen(); }; + test.Db.Db.UniqueBool.OnDelete += (_, row) => { Require(row.B && row.Data == 10, "Unexpected unique_bool delete row"); Seen(); }; + test.Db.Db.UniqueString.OnDelete += (_, row) => { Require(row.S == "key" && row.Data == 10, "Unexpected unique_string delete row"); Seen(); }; + + test.Db.Reducers.InsertUniqueU8(1, 10); + test.Db.Reducers.InsertUniqueU16(2, 10); + test.Db.Reducers.InsertUniqueU32(3, 10); + test.Db.Reducers.InsertUniqueU64(4, 10); + test.Db.Reducers.InsertUniqueU128(u128, 10); + test.Db.Reducers.InsertUniqueU256(u256, 10); + test.Db.Reducers.InsertUniqueI8(-1, 10); + test.Db.Reducers.InsertUniqueI16(-2, 10); + test.Db.Reducers.InsertUniqueI32(-3, 10); + test.Db.Reducers.InsertUniqueI64(-4, 10); + test.Db.Reducers.InsertUniqueI128(i128, 10); + test.Db.Reducers.InsertUniqueI256(i256, 10); + test.Db.Reducers.InsertUniqueBool(true, 10); + test.Db.Reducers.InsertUniqueString("key", 10); + test.FrameTickUntil(() => test.Db.Db.UniqueString.Count == 1); + + test.Db.Reducers.DeleteUniqueU8(1); + test.Db.Reducers.DeleteUniqueU16(2); + test.Db.Reducers.DeleteUniqueU32(3); + test.Db.Reducers.DeleteUniqueU64(4); + test.Db.Reducers.DeleteUniqueU128(u128); + test.Db.Reducers.DeleteUniqueU256(u256); + test.Db.Reducers.DeleteUniqueI8(-1); + test.Db.Reducers.DeleteUniqueI16(-2); + test.Db.Reducers.DeleteUniqueI32(-3); + test.Db.Reducers.DeleteUniqueI64(-4); + test.Db.Reducers.DeleteUniqueI128(i128); + test.Db.Reducers.DeleteUniqueI256(i256); + test.Db.Reducers.DeleteUniqueBool(true); + test.Db.Reducers.DeleteUniqueString("key"); + test.FrameTickUntil(() => remaining == 0); } void RunUpdatePrimitive() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkU32())); - ExpectPkU32Update(test, 9, 11, 12); + using var test = ConnectAndSubscribeAll(); + var u128 = new U128(0, 5); + var u256 = new U256(new U128(0, 0), new U128(0, 6)); + var i128 = new I128(0, 7); + var i256 = new I256(new U128(0, 0), new U128(0, 8)); + var remaining = 14; + void Seen() => remaining--; + + test.Db.Db.PkU8.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == 1 && oldRow.Data == 10 && newRow.N == 1 && newRow.Data == 20, "Unexpected pk_u8 update"); Seen(); }; + test.Db.Db.PkU16.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == 2 && oldRow.Data == 10 && newRow.N == 2 && newRow.Data == 20, "Unexpected pk_u16 update"); Seen(); }; + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == 3 && oldRow.Data == 10 && newRow.N == 3 && newRow.Data == 20, "Unexpected pk_u32 update"); Seen(); }; + test.Db.Db.PkU64.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == 4 && oldRow.Data == 10 && newRow.N == 4 && newRow.Data == 20, "Unexpected pk_u64 update"); Seen(); }; + test.Db.Db.PkU128.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N.Equals(u128) && oldRow.Data == 10 && newRow.N.Equals(u128) && newRow.Data == 20, "Unexpected pk_u128 update"); Seen(); }; + test.Db.Db.PkU256.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N.Equals(u256) && oldRow.Data == 10 && newRow.N.Equals(u256) && newRow.Data == 20, "Unexpected pk_u256 update"); Seen(); }; + test.Db.Db.PkI8.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == -1 && oldRow.Data == 10 && newRow.N == -1 && newRow.Data == 20, "Unexpected pk_i8 update"); Seen(); }; + test.Db.Db.PkI16.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == -2 && oldRow.Data == 10 && newRow.N == -2 && newRow.Data == 20, "Unexpected pk_i16 update"); Seen(); }; + test.Db.Db.PkI32.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == -3 && oldRow.Data == 10 && newRow.N == -3 && newRow.Data == 20, "Unexpected pk_i32 update"); Seen(); }; + test.Db.Db.PkI64.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == -4 && oldRow.Data == 10 && newRow.N == -4 && newRow.Data == 20, "Unexpected pk_i64 update"); Seen(); }; + test.Db.Db.PkI128.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N.Equals(i128) && oldRow.Data == 10 && newRow.N.Equals(i128) && newRow.Data == 20, "Unexpected pk_i128 update"); Seen(); }; + test.Db.Db.PkI256.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N.Equals(i256) && oldRow.Data == 10 && newRow.N.Equals(i256) && newRow.Data == 20, "Unexpected pk_i256 update"); Seen(); }; + test.Db.Db.PkBool.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.B && oldRow.Data == 10 && newRow.B && newRow.Data == 20, "Unexpected pk_bool update"); Seen(); }; + test.Db.Db.PkString.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.S == "key" && oldRow.Data == 10 && newRow.S == "key" && newRow.Data == 20, "Unexpected pk_string update"); Seen(); }; + + test.Db.Reducers.InsertPkU8(1, 10); + test.Db.Reducers.InsertPkU16(2, 10); + test.Db.Reducers.InsertPkU32(3, 10); + test.Db.Reducers.InsertPkU64(4, 10); + test.Db.Reducers.InsertPkU128(u128, 10); + test.Db.Reducers.InsertPkU256(u256, 10); + test.Db.Reducers.InsertPkI8(-1, 10); + test.Db.Reducers.InsertPkI16(-2, 10); + test.Db.Reducers.InsertPkI32(-3, 10); + test.Db.Reducers.InsertPkI64(-4, 10); + test.Db.Reducers.InsertPkI128(i128, 10); + test.Db.Reducers.InsertPkI256(i256, 10); + test.Db.Reducers.InsertPkBool(true, 10); + test.Db.Reducers.InsertPkString("key", 10); + test.FrameTickUntil(() => test.Db.Db.PkString.Count == 1); + + test.Db.Reducers.UpdatePkU8(1, 20); + test.Db.Reducers.UpdatePkU16(2, 20); + test.Db.Reducers.UpdatePkU32(3, 20); + test.Db.Reducers.UpdatePkU64(4, 20); + test.Db.Reducers.UpdatePkU128(u128, 20); + test.Db.Reducers.UpdatePkU256(u256, 20); + test.Db.Reducers.UpdatePkI8(-1, 20); + test.Db.Reducers.UpdatePkI16(-2, 20); + test.Db.Reducers.UpdatePkI32(-3, 20); + test.Db.Reducers.UpdatePkI64(-4, 20); + test.Db.Reducers.UpdatePkI128(i128, 20); + test.Db.Reducers.UpdatePkI256(i256, 20); + test.Db.Reducers.UpdatePkBool(true, 20); + test.Db.Reducers.UpdatePkString("key", 20); + test.FrameTickUntil(() => remaining == 0); } void RunInsertIdentity() @@ -621,93 +707,191 @@ void RunFailReducer() void RunInsertVec() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() - .AddQuery(qb => qb.From.VecI32()) - .AddQuery(qb => qb.From.VecString()) - .AddQuery(qb => qb.From.VecUuid())); - var remaining = 3; + using var test = ConnectAndSubscribeAll(); + var u128 = new List { new(0, 5), new(0, 6) }; + var u256 = new List { new(new U128(0, 0), new U128(0, 7)), new(new U128(0, 0), new U128(0, 8)) }; + var i128 = new List { new(0, 9), new(0, 10) }; + var i256 = new List { new(new U128(0, 0), new U128(0, 11)), new(new U128(0, 0), new U128(0, 12)) }; var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); - test.Db.Db.VecI32.OnInsert += (_, row) => { Require(row.N.SequenceEqual(new[] { -1, 0, 42 }), "VecI32 did not round-trip"); remaining--; }; - test.Db.Db.VecString.OnInsert += (_, row) => { Require(row.S.SequenceEqual(new[] { "alpha", "beta" }), "VecString did not round-trip"); remaining--; }; - test.Db.Db.VecUuid.OnInsert += (_, row) => { Require(row.U.SequenceEqual(new[] { uuid }), "VecUuid did not round-trip"); remaining--; }; + var timestamp = new Timestamp(1_234_567); + var duration = new TimeDuration(9_876); + var remaining = 19; + void Seen() => remaining--; + + test.Db.Db.VecU8.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new byte[] { 0, 1 }, "VecU8 did not round-trip"); Seen(); }; + test.Db.Db.VecU16.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new ushort[] { 0, 1 }, "VecU16 did not round-trip"); Seen(); }; + test.Db.Db.VecU32.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new uint[] { 0, 1 }, "VecU32 did not round-trip"); Seen(); }; + test.Db.Db.VecU64.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new ulong[] { 0, 1 }, "VecU64 did not round-trip"); Seen(); }; + test.Db.Db.VecU128.OnInsert += (_, row) => { RequireSequenceEqual(row.N, u128, "VecU128 did not round-trip"); Seen(); }; + test.Db.Db.VecU256.OnInsert += (_, row) => { RequireSequenceEqual(row.N, u256, "VecU256 did not round-trip"); Seen(); }; + test.Db.Db.VecI8.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new sbyte[] { 0, 1 }, "VecI8 did not round-trip"); Seen(); }; + test.Db.Db.VecI16.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new short[] { 0, 1 }, "VecI16 did not round-trip"); Seen(); }; + test.Db.Db.VecI32.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new[] { -1, 0, 42 }, "VecI32 did not round-trip"); Seen(); }; + test.Db.Db.VecI64.OnInsert += (_, row) => { RequireSequenceEqual(row.N, new long[] { 0, 1 }, "VecI64 did not round-trip"); Seen(); }; + test.Db.Db.VecI128.OnInsert += (_, row) => { RequireSequenceEqual(row.N, i128, "VecI128 did not round-trip"); Seen(); }; + test.Db.Db.VecI256.OnInsert += (_, row) => { RequireSequenceEqual(row.N, i256, "VecI256 did not round-trip"); Seen(); }; + test.Db.Db.VecBool.OnInsert += (_, row) => { RequireSequenceEqual(row.B, new[] { false, true }, "VecBool did not round-trip"); Seen(); }; + test.Db.Db.VecF32.OnInsert += (_, row) => { Require(row.F.Count == 2 && Math.Abs(row.F[0] - 0.0f) < 0.001f && Math.Abs(row.F[1] - 1.0f) < 0.001f, "VecF32 did not round-trip"); Seen(); }; + test.Db.Db.VecF64.OnInsert += (_, row) => { Require(row.F.Count == 2 && Math.Abs(row.F[0] - 0.0) < 0.001 && Math.Abs(row.F[1] - 1.0) < 0.001, "VecF64 did not round-trip"); Seen(); }; + test.Db.Db.VecString.OnInsert += (_, row) => { RequireSequenceEqual(row.S, new[] { "zero", "one" }, "VecString did not round-trip"); Seen(); }; + test.Db.Db.VecIdentity.OnInsert += (_, row) => { RequireSequenceEqual(row.I, new[] { test.Identity }, "VecIdentity did not round-trip"); Seen(); }; + test.Db.Db.VecConnectionId.OnInsert += (_, row) => { RequireSequenceEqual(row.A, new[] { test.Db.ConnectionId }, "VecConnectionId did not round-trip"); Seen(); }; + test.Db.Db.VecTimestamp.OnInsert += (_, row) => { RequireSequenceEqual(row.T, new[] { timestamp }, "VecTimestamp did not round-trip"); Seen(); }; + + test.Db.Reducers.InsertVecU8(new() { 0, 1 }); + test.Db.Reducers.InsertVecU16(new() { 0, 1 }); + test.Db.Reducers.InsertVecU32(new() { 0, 1 }); + test.Db.Reducers.InsertVecU64(new() { 0, 1 }); + test.Db.Reducers.InsertVecU128(u128); + test.Db.Reducers.InsertVecU256(u256); + test.Db.Reducers.InsertVecI8(new() { 0, 1 }); + test.Db.Reducers.InsertVecI16(new() { 0, 1 }); test.Db.Reducers.InsertVecI32(new() { -1, 0, 42 }); - test.Db.Reducers.InsertVecString(new() { "alpha", "beta" }); - test.Db.Reducers.InsertVecUuid(new() { uuid }); + test.Db.Reducers.InsertVecI64(new() { 0, 1 }); + test.Db.Reducers.InsertVecI128(i128); + test.Db.Reducers.InsertVecI256(i256); + test.Db.Reducers.InsertVecBool(new() { false, true }); + test.Db.Reducers.InsertVecF32(new() { 0.0f, 1.0f }); + test.Db.Reducers.InsertVecF64(new() { 0.0, 1.0 }); + test.Db.Reducers.InsertVecString(new() { "zero", "one" }); + test.Db.Reducers.InsertVecIdentity(new() { test.Identity }); + test.Db.Reducers.InsertVecConnectionId(new() { test.Db.ConnectionId }); + test.Db.Reducers.InsertVecTimestamp(new() { timestamp }); test.FrameTickUntil(() => remaining == 0); } void RunInsertOptionSome() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() - .AddQuery(qb => qb.From.OptionI32()) - .AddQuery(qb => qb.From.OptionString()) - .AddQuery(qb => qb.From.OptionUuid())); - var remaining = 3; - var uuid = Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10"); - test.Db.Db.OptionI32.OnInsert += (_, row) => { Require(row.N == 42, "OptionI32 Some did not round-trip"); remaining--; }; - test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == "present", "OptionString Some did not round-trip"); remaining--; }; - test.Db.Db.OptionUuid.OnInsert += (_, row) => { Require(row.U == uuid, "OptionUuid Some did not round-trip"); remaining--; }; + using var test = ConnectAndSubscribeAll(); + var primitive = EveryPrimitiveStructValue(test); + var vecOption = new List { 0, null }; + var remaining = 6; + void Seen() => remaining--; + + test.Db.Db.OptionI32.OnInsert += (_, row) => { Require(row.N == 42, "OptionI32 Some did not round-trip"); Seen(); }; + test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == "string", "OptionString Some did not round-trip"); Seen(); }; + test.Db.Db.OptionIdentity.OnInsert += (_, row) => { Require(row.I == test.Identity, "OptionIdentity Some did not round-trip"); Seen(); }; + test.Db.Db.OptionSimpleEnum.OnInsert += (_, row) => { Require(row.E == SimpleEnum.Zero, "OptionSimpleEnum Some did not round-trip"); Seen(); }; + test.Db.Db.OptionEveryPrimitiveStruct.OnInsert += (_, row) => { Require(row.S == primitive, "OptionEveryPrimitiveStruct Some did not round-trip"); Seen(); }; + test.Db.Db.OptionVecOptionI32.OnInsert += (_, row) => { Require(row.V != null && row.V.SequenceEqual(vecOption), "OptionVecOptionI32 Some did not round-trip"); Seen(); }; + test.Db.Reducers.InsertOptionI32(42); - test.Db.Reducers.InsertOptionString("present"); - test.Db.Reducers.InsertOptionUuid(uuid); + test.Db.Reducers.InsertOptionString("string"); + test.Db.Reducers.InsertOptionIdentity(test.Identity); + test.Db.Reducers.InsertOptionSimpleEnum(SimpleEnum.Zero); + test.Db.Reducers.InsertOptionEveryPrimitiveStruct(primitive); + test.Db.Reducers.InsertOptionVecOptionI32(vecOption); test.FrameTickUntil(() => remaining == 0); } void RunInsertOptionNone() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() - .AddQuery(qb => qb.From.OptionI32()) - .AddQuery(qb => qb.From.OptionString()) - .AddQuery(qb => qb.From.OptionUuid())); - var remaining = 3; - test.Db.Db.OptionI32.OnInsert += (_, row) => { Require(row.N == null, "OptionI32 None did not round-trip"); remaining--; }; - test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == null, "OptionString None did not round-trip"); remaining--; }; - test.Db.Db.OptionUuid.OnInsert += (_, row) => { Require(row.U == null, "OptionUuid None did not round-trip"); remaining--; }; + using var test = ConnectAndSubscribeAll(); + var remaining = 6; + void Seen() => remaining--; + + test.Db.Db.OptionI32.OnInsert += (_, row) => { Require(row.N == null, "OptionI32 None did not round-trip"); Seen(); }; + test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == null, "OptionString None did not round-trip"); Seen(); }; + test.Db.Db.OptionIdentity.OnInsert += (_, row) => { Require(row.I == null, "OptionIdentity None did not round-trip"); Seen(); }; + test.Db.Db.OptionSimpleEnum.OnInsert += (_, row) => { Require(row.E == null, "OptionSimpleEnum None did not round-trip"); Seen(); }; + test.Db.Db.OptionEveryPrimitiveStruct.OnInsert += (_, row) => { Require(row.S == null, "OptionEveryPrimitiveStruct None did not round-trip"); Seen(); }; + test.Db.Db.OptionVecOptionI32.OnInsert += (_, row) => { Require(row.V == null, "OptionVecOptionI32 None did not round-trip"); Seen(); }; + test.Db.Reducers.InsertOptionI32(null); test.Db.Reducers.InsertOptionString(null); - test.Db.Reducers.InsertOptionUuid(null); + test.Db.Reducers.InsertOptionIdentity(null); + test.Db.Reducers.InsertOptionSimpleEnum(null); + test.Db.Reducers.InsertOptionEveryPrimitiveStruct(null); + test.Db.Reducers.InsertOptionVecOptionI32(null); test.FrameTickUntil(() => remaining == 0); } void RunInsertStruct() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder() - .AddQuery(qb => qb.From.OneByteStruct()) - .AddQuery(qb => qb.From.OneEveryPrimitiveStruct())); - var remaining = 2; + using var test = ConnectAndSubscribeAll(); var primitive = EveryPrimitiveStructValue(test); - test.Db.Db.OneByteStruct.OnInsert += (_, row) => { Require(row.S.B == 99, "ByteStruct did not round-trip"); remaining--; }; - test.Db.Db.OneEveryPrimitiveStruct.OnInsert += (_, row) => { Require(row.S == primitive, "EveryPrimitiveStruct did not round-trip"); remaining--; }; - test.Db.Reducers.InsertOneByteStruct(new ByteStruct { B = 99 }); + var vec = EveryVecStructValue(test); + var byteStruct = new ByteStruct { B = 99 }; + var expected = new HashSet + { + "one_byte_struct", + "one_every_primitive_struct", + "one_every_vec_struct", + "vec_unit_struct", + "vec_byte_struct", + "vec_every_primitive_struct", + "vec_every_vec_struct", + }; + var seen = new HashSet(); + void Seen(string name) + { + Require(seen.Add(name), $"{name} callback fired more than once"); + } + + test.Db.Db.OneByteStruct.OnInsert += (_, row) => { RequireByteStructEqual(row.S, byteStruct, "ByteStruct did not round-trip"); Seen("one_byte_struct"); }; + test.Db.Db.OneEveryPrimitiveStruct.OnInsert += (_, row) => { RequireEveryPrimitiveStructEqual(row.S, primitive, "EveryPrimitiveStruct did not round-trip"); Seen("one_every_primitive_struct"); }; + test.Db.Db.OneEveryVecStruct.OnInsert += (_, row) => { RequireEveryVecStructEqual(row.S, vec, "EveryVecStruct did not round-trip"); Seen("one_every_vec_struct"); }; + test.Db.Db.VecUnitStruct.OnInsert += (_, row) => { RequireSequenceEqual(row.S, new[] { new UnitStruct() }, "VecUnitStruct did not round-trip"); Seen("vec_unit_struct"); }; + test.Db.Db.VecByteStruct.OnInsert += (_, row) => { RequireStructListEqual(row.S, new[] { byteStruct }, RequireByteStructEqual, "VecByteStruct did not round-trip"); Seen("vec_byte_struct"); }; + test.Db.Db.VecEveryPrimitiveStruct.OnInsert += (_, row) => { RequireStructListEqual(row.S, new[] { primitive }, RequireEveryPrimitiveStructEqual, "VecEveryPrimitiveStruct did not round-trip"); Seen("vec_every_primitive_struct"); }; + test.Db.Db.VecEveryVecStruct.OnInsert += (_, row) => { RequireStructListEqual(row.S, new[] { vec }, RequireEveryVecStructEqual, "VecEveryVecStruct did not round-trip"); Seen("vec_every_vec_struct"); }; + + test.Db.Reducers.InsertOneByteStruct(byteStruct); test.Db.Reducers.InsertOneEveryPrimitiveStruct(primitive); - test.FrameTickUntil(() => remaining == 0); + test.Db.Reducers.InsertOneEveryVecStruct(vec); + test.Db.Reducers.InsertVecUnitStruct(new() { new UnitStruct() }); + test.Db.Reducers.InsertVecByteStruct(new() { byteStruct }); + test.Db.Reducers.InsertVecEveryPrimitiveStruct(new() { primitive }); + test.Db.Reducers.InsertVecEveryVecStruct(new() { vec }); + test.FrameTickUntil( + () => seen.SetEquals(expected), + timeoutMessage: () => $"Timed out waiting for struct callbacks. Missing: {string.Join(", ", expected.Except(seen).OrderBy(name => name))}"); } void RunInsertSimpleEnum() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneSimpleEnum())); - var inserted = false; + using var test = ConnectAndSubscribeAll(); + var remaining = 2; + void Seen() => remaining--; + test.Db.Db.OneSimpleEnum.OnInsert += (_, row) => { - Require(row.E == SimpleEnum.Two, "SimpleEnum did not round-trip"); - inserted = true; + Require(row.E == SimpleEnum.One, "SimpleEnum did not round-trip"); + Seen(); }; - test.Db.Reducers.InsertOneSimpleEnum(SimpleEnum.Two); - test.FrameTickUntil(() => inserted); + test.Db.Db.VecSimpleEnum.OnInsert += (_, row) => + { + RequireSequenceEqual(row.E, new[] { SimpleEnum.Zero, SimpleEnum.One, SimpleEnum.Two }, "VecSimpleEnum did not round-trip"); + Seen(); + }; + + test.Db.Reducers.InsertOneSimpleEnum(SimpleEnum.One); + test.Db.Reducers.InsertVecSimpleEnum(new() { SimpleEnum.Zero, SimpleEnum.One, SimpleEnum.Two }); + test.FrameTickUntil(() => remaining == 0); } void RunInsertEnumWithPayload() { - using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneEnumWithPayload())); - var inserted = false; - var payload = new EnumWithPayload.U8(17); + using var test = ConnectAndSubscribeAll(); + var onePayload = new EnumWithPayload.U8(17); + var payloads = EnumPayloadValues(test); + var remaining = 2; + void Seen() => remaining--; + test.Db.Db.OneEnumWithPayload.OnInsert += (_, row) => { - Require(row.E == payload, "EnumWithPayload did not round-trip"); - inserted = true; + Require(row.E == onePayload, "EnumWithPayload did not round-trip"); + Seen(); }; - test.Db.Reducers.InsertOneEnumWithPayload(payload); - test.FrameTickUntil(() => inserted); + test.Db.Db.VecEnumWithPayload.OnInsert += (_, row) => + { + RequireEnumPayloadListEqual(row.E, payloads, "VecEnumWithPayload did not round-trip"); + Seen(); + }; + + test.Db.Reducers.InsertOneEnumWithPayload(onePayload); + test.Db.Reducers.InsertVecEnumWithPayload(payloads); + test.FrameTickUntil(() => remaining == 0); } void RunInsertDeleteLargeTable() @@ -1415,6 +1599,74 @@ void Require(bool condition, string message) } } +void RequireByteStructEqual(ByteStruct actual, ByteStruct expected, string message) +{ + Require(actual.B == expected.B, message); +} + +void RequireEveryPrimitiveStructEqual(EveryPrimitiveStruct actual, EveryPrimitiveStruct expected, string message) +{ + Require( + actual.A == expected.A && + actual.B == expected.B && + actual.C == expected.C && + actual.D == expected.D && + actual.E == expected.E && + actual.F == expected.F && + actual.G == expected.G && + actual.H == expected.H && + actual.I == expected.I && + actual.J == expected.J && + actual.K == expected.K && + actual.L == expected.L && + actual.M == expected.M && + actual.N == expected.N && + actual.O == expected.O && + actual.P == expected.P && + actual.Q == expected.Q && + actual.R == expected.R && + actual.S == expected.S && + actual.T == expected.T && + actual.U == expected.U, + message); +} + +void RequireEveryVecStructEqual(EveryVecStruct actual, EveryVecStruct expected, string message) +{ + Require( + actual.A.SequenceEqual(expected.A) && + actual.B.SequenceEqual(expected.B) && + actual.C.SequenceEqual(expected.C) && + actual.D.SequenceEqual(expected.D) && + actual.E.SequenceEqual(expected.E) && + actual.F.SequenceEqual(expected.F) && + actual.G.SequenceEqual(expected.G) && + actual.H.SequenceEqual(expected.H) && + actual.I.SequenceEqual(expected.I) && + actual.J.SequenceEqual(expected.J) && + actual.K.SequenceEqual(expected.K) && + actual.L.SequenceEqual(expected.L) && + actual.M.SequenceEqual(expected.M) && + actual.N.SequenceEqual(expected.N) && + actual.O.SequenceEqual(expected.O) && + actual.P.SequenceEqual(expected.P) && + actual.Q.SequenceEqual(expected.Q) && + actual.R.SequenceEqual(expected.R) && + actual.S.SequenceEqual(expected.S) && + actual.T.SequenceEqual(expected.T) && + actual.U.SequenceEqual(expected.U), + message); +} + +void RequireStructListEqual(IReadOnlyList actual, IReadOnlyList expected, Action requireEqual, string message) +{ + Require(actual.Count == expected.Count, message); + for (var i = 0; i < actual.Count; i++) + { + requireEqual(actual[i], expected[i], message); + } +} + OnceFlag Once(string name) { var seen = false; @@ -1428,6 +1680,84 @@ OnceFlag Once(string name) }, () => seen); } +List EnumPayloadValues(HarnessConnection test) => new() +{ + new EnumWithPayload.U8(0), + new EnumWithPayload.U16(1), + new EnumWithPayload.U32(2), + new EnumWithPayload.U64(3), + new EnumWithPayload.U128(new U128(0, 4)), + new EnumWithPayload.U256(new U256(new U128(0, 0), new U128(0, 5))), + new EnumWithPayload.I8(0), + new EnumWithPayload.I16(-1), + new EnumWithPayload.I32(-2), + new EnumWithPayload.I64(-3), + new EnumWithPayload.I128(new I128(0, 4)), + new EnumWithPayload.I256(new I256(new U128(0, 0), new U128(0, 5))), + new EnumWithPayload.Bool(true), + new EnumWithPayload.F32(0.0f), + new EnumWithPayload.F64(100.0), + new EnumWithPayload.Str("enum holds string"), + new EnumWithPayload.Identity(test.Identity), + new EnumWithPayload.ConnectionId(test.Db.ConnectionId), + new EnumWithPayload.Timestamp(new Timestamp(1_234_567)), + new EnumWithPayload.Uuid(Uuid.Parse("01890f3d-8120-7cc8-9a1f-cd1224fb3a10")), + new EnumWithPayload.Bytes(new() { 0xde, 0xad, 0xbe, 0xef }), + new EnumWithPayload.Ints(new() { 0, 1, 2 }), + new EnumWithPayload.Strings(new() { "enum", "of", "vec", "of", "strings" }), + new EnumWithPayload.SimpleEnums(new() { SimpleEnum.Zero, SimpleEnum.One, SimpleEnum.Two }), +}; + +void RequireSequenceEqual(IEnumerable actual, IEnumerable expected, string message) +{ + if (!actual.SequenceEqual(expected)) + { + throw new Exception(message); + } +} + +void RequireEnumPayloadListEqual(IReadOnlyList actual, IReadOnlyList expected, string message) +{ + Require(actual.Count == expected.Count, message); + for (var i = 0; i < actual.Count; i++) + { + RequireEnumPayloadEqual(actual[i], expected[i], message); + } +} + +void RequireEnumPayloadEqual(EnumWithPayload actual, EnumWithPayload expected, string message) +{ + var equal = (actual, expected) switch + { + (EnumWithPayload.U8 a, EnumWithPayload.U8 e) => a.U8_ == e.U8_, + (EnumWithPayload.U16 a, EnumWithPayload.U16 e) => a.U16_ == e.U16_, + (EnumWithPayload.U32 a, EnumWithPayload.U32 e) => a.U32_ == e.U32_, + (EnumWithPayload.U64 a, EnumWithPayload.U64 e) => a.U64_ == e.U64_, + (EnumWithPayload.U128 a, EnumWithPayload.U128 e) => a.U128_ == e.U128_, + (EnumWithPayload.U256 a, EnumWithPayload.U256 e) => a.U256_ == e.U256_, + (EnumWithPayload.I8 a, EnumWithPayload.I8 e) => a.I8_ == e.I8_, + (EnumWithPayload.I16 a, EnumWithPayload.I16 e) => a.I16_ == e.I16_, + (EnumWithPayload.I32 a, EnumWithPayload.I32 e) => a.I32_ == e.I32_, + (EnumWithPayload.I64 a, EnumWithPayload.I64 e) => a.I64_ == e.I64_, + (EnumWithPayload.I128 a, EnumWithPayload.I128 e) => a.I128_ == e.I128_, + (EnumWithPayload.I256 a, EnumWithPayload.I256 e) => a.I256_ == e.I256_, + (EnumWithPayload.Bool a, EnumWithPayload.Bool e) => a.Bool_ == e.Bool_, + (EnumWithPayload.F32 a, EnumWithPayload.F32 e) => a.F32_ == e.F32_, + (EnumWithPayload.F64 a, EnumWithPayload.F64 e) => a.F64_ == e.F64_, + (EnumWithPayload.Str a, EnumWithPayload.Str e) => a.Str_ == e.Str_, + (EnumWithPayload.Identity a, EnumWithPayload.Identity e) => a.Identity_ == e.Identity_, + (EnumWithPayload.ConnectionId a, EnumWithPayload.ConnectionId e) => a.ConnectionId_ == e.ConnectionId_, + (EnumWithPayload.Timestamp a, EnumWithPayload.Timestamp e) => a.Timestamp_ == e.Timestamp_, + (EnumWithPayload.Uuid a, EnumWithPayload.Uuid e) => a.Uuid_ == e.Uuid_, + (EnumWithPayload.Bytes a, EnumWithPayload.Bytes e) => a.Bytes_.SequenceEqual(e.Bytes_), + (EnumWithPayload.Ints a, EnumWithPayload.Ints e) => a.Ints_.SequenceEqual(e.Ints_), + (EnumWithPayload.Strings a, EnumWithPayload.Strings e) => a.Strings_.SequenceEqual(e.Strings_), + (EnumWithPayload.SimpleEnums a, EnumWithPayload.SimpleEnums e) => a.SimpleEnums_.SequenceEqual(e.SimpleEnums_), + _ => false, + }; + Require(equal, message); +} + void FrameTickUntil(IEnumerable connections, Func isComplete, int timeoutSeconds = 20) { var list = connections.ToArray(); @@ -1478,7 +1808,7 @@ public HarnessConnection(DbConnection db, bool allowCleanDisconnect = false) this.allowCleanDisconnect = allowCleanDisconnect; } - public void FrameTickUntil(Func isComplete, int timeoutSeconds = 20) + public void FrameTickUntil(Func isComplete, int timeoutSeconds = 20, Func? timeoutMessage = null) { var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); while (!isComplete()) @@ -1487,7 +1817,7 @@ public void FrameTickUntil(Func isComplete, int timeoutSeconds = 20) Thread.Sleep(25); if (DateTime.UtcNow > deadline) { - throw new TimeoutException($"Timed out after {timeoutSeconds} seconds"); + throw new TimeoutException(timeoutMessage?.Invoke() ?? $"Timed out after {timeoutSeconds} seconds"); } } } From 04228c883523ad8df7adca8182f4607c35ca4baa Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 13:28:43 -0500 Subject: [PATCH 08/18] Making some tests more strict --- sdks/csharp/tests/sdk-test-client/Program.cs | 58 ++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index 36e7085c48f..7002ccf42b8 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -772,7 +772,12 @@ void RunInsertOptionSome() test.Db.Db.OptionString.OnInsert += (_, row) => { Require(row.S == "string", "OptionString Some did not round-trip"); Seen(); }; test.Db.Db.OptionIdentity.OnInsert += (_, row) => { Require(row.I == test.Identity, "OptionIdentity Some did not round-trip"); Seen(); }; test.Db.Db.OptionSimpleEnum.OnInsert += (_, row) => { Require(row.E == SimpleEnum.Zero, "OptionSimpleEnum Some did not round-trip"); Seen(); }; - test.Db.Db.OptionEveryPrimitiveStruct.OnInsert += (_, row) => { Require(row.S == primitive, "OptionEveryPrimitiveStruct Some did not round-trip"); Seen(); }; + test.Db.Db.OptionEveryPrimitiveStruct.OnInsert += (_, row) => + { + Require(row.S != null, "OptionEveryPrimitiveStruct Some did not round-trip"); + RequireEveryPrimitiveStructEqual(row.S, primitive, "OptionEveryPrimitiveStruct Some did not round-trip"); + Seen(); + }; test.Db.Db.OptionVecOptionI32.OnInsert += (_, row) => { Require(row.V != null && row.V.SequenceEqual(vecOption), "OptionVecOptionI32 Some did not round-trip"); Seen(); }; test.Db.Reducers.InsertOptionI32(42); @@ -900,19 +905,35 @@ void RunInsertDeleteLargeTable() var large = LargeTableValue(test); var inserted = false; var deleted = false; + var insertReducer = false; + var deleteReducer = false; + test.Db.Reducers.OnInsertLargeTable += ( + ctx, + _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _) => + { + RequireCommitted(ctx.Event.Status); + insertReducer = true; + }; + test.Db.Reducers.OnDeleteLargeTable += ( + ctx, + _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _) => + { + RequireCommitted(ctx.Event.Status); + deleteReducer = true; + }; test.Db.Db.LargeTable.OnInsert += (_, row) => { - Require(row == large, "LargeTable insert did not round-trip"); + RequireLargeTableEqual(row, large, "LargeTable insert did not round-trip"); inserted = true; CallDeleteLargeTable(test, large); }; test.Db.Db.LargeTable.OnDelete += (_, row) => { - Require(row == large, "LargeTable delete did not round-trip"); + RequireLargeTableEqual(row, large, "LargeTable delete did not round-trip"); deleted = true; }; CallInsertLargeTable(test, large); - test.FrameTickUntil(() => inserted && deleted); + test.FrameTickUntil(() => inserted && deleted && insertReducer && deleteReducer); } void RunInsertPrimitivesAsStrings() @@ -1135,7 +1156,7 @@ void RunRowDeduplicationRJoinSAndRJoinT() test.Db.Db.UniqueU32.OnInsert += (_, _) => uniqueInserts++; test.Db.Reducers.InsertPkU32(42, 0xbeef); test.FrameTickUntil(() => pkInsert && pkDelete && pkTwoInsert); - Require(uniqueInserts <= 1, $"Expected at most one deduplicated unique_u32 insert, got {uniqueInserts}"); + Require(uniqueInserts == 1, $"Expected exactly one deduplicated unique_u32 insert, got {uniqueInserts}"); } void RunLhsJoinUpdate(bool disjoint) @@ -1658,6 +1679,33 @@ void RequireEveryVecStructEqual(EveryVecStruct actual, EveryVecStruct expected, message); } +void RequireLargeTableEqual(LargeTable actual, LargeTable expected, string message) +{ + Require( + actual.A == expected.A && + actual.B == expected.B && + actual.C == expected.C && + actual.D == expected.D && + actual.E == expected.E && + actual.F == expected.F && + actual.G == expected.G && + actual.H == expected.H && + actual.I == expected.I && + actual.J == expected.J && + actual.K == expected.K && + actual.L == expected.L && + actual.M == expected.M && + actual.N == expected.N && + actual.O == expected.O && + actual.P == expected.P && + actual.Q == expected.Q, + message); + RequireEnumPayloadEqual(actual.R, expected.R, message); + RequireByteStructEqual(actual.T, expected.T, message); + RequireEveryPrimitiveStructEqual(actual.U, expected.U, message); + RequireEveryVecStructEqual(actual.V, expected.V, message); +} + void RequireStructListEqual(IReadOnlyList actual, IReadOnlyList expected, Action requireEqual, string message) { Require(actual.Count == expected.Count, message); From 56dae7182e9e205d000ce37cf1af86646a948d6f Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 13:42:04 -0500 Subject: [PATCH 09/18] Add committed reducer callback assertions --- sdks/csharp/tests/sdk-test-client/Program.cs | 172 +++++++++++++++++-- 1 file changed, 155 insertions(+), 17 deletions(-) diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index 7002ccf42b8..8f3379b7419 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -465,6 +465,12 @@ void RunInsertCallerIdentity() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneIdentity())); var inserted = false; + var reducerSeen = false; + test.Db.Reducers.OnInsertCallerOneIdentity += ctx => + { + RequireCommitted(ctx.Event.Status); + reducerSeen = true; + }; test.Db.Db.OneIdentity.OnInsert += (_, row) => { @@ -473,7 +479,7 @@ void RunInsertCallerIdentity() }; test.Db.Reducers.InsertCallerOneIdentity(); - test.FrameTickUntil(() => inserted); + test.FrameTickUntil(() => inserted && reducerSeen); } void RunDeleteIdentity() @@ -532,13 +538,19 @@ void RunInsertCallerConnectionId() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneConnectionId())); var inserted = false; + var reducerSeen = false; + test.Db.Reducers.OnInsertCallerOneConnectionId += ctx => + { + RequireCommitted(ctx.Event.Status); + reducerSeen = true; + }; test.Db.Db.OneConnectionId.OnInsert += (_, row) => { Require(row.A == test.Db.ConnectionId, "Caller ConnectionId did not match connection state"); inserted = true; }; test.Db.Reducers.InsertCallerOneConnectionId(); - test.FrameTickUntil(() => inserted); + test.FrameTickUntil(() => inserted && reducerSeen); } void RunDeleteConnectionId() @@ -590,6 +602,12 @@ void RunInsertCallTimestamp() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneTimestamp())); var inserted = false; + var reducerSeen = false; + test.Db.Reducers.OnInsertCallTimestamp += ctx => + { + RequireCommitted(ctx.Event.Status); + reducerSeen = true; + }; test.Db.Db.OneTimestamp.OnInsert += (ctx, row) => { Require(ctx.Event is Event.Reducer, "Expected reducer event for insert_call_timestamp"); @@ -597,7 +615,7 @@ void RunInsertCallTimestamp() inserted = true; }; test.Db.Reducers.InsertCallTimestamp(); - test.FrameTickUntil(() => inserted); + test.FrameTickUntil(() => inserted && reducerSeen); } void RunInsertUuid() @@ -614,21 +632,35 @@ void RunInsertUuid() test.FrameTickUntil(() => inserted); } -void RunInsertCallUuidV4() => RunGeneratedUuid(test => test.Db.Reducers.InsertCallUuidV4()); +void RunInsertCallUuidV4() => RunGeneratedUuid( + test => test.Db.Reducers.InsertCallUuidV4(), + (test, mark) => test.Db.Reducers.OnInsertCallUuidV4 += ctx => + { + RequireCommitted(ctx.Event.Status); + mark(); + }); -void RunInsertCallUuidV7() => RunGeneratedUuid(test => test.Db.Reducers.InsertCallUuidV7()); +void RunInsertCallUuidV7() => RunGeneratedUuid( + test => test.Db.Reducers.InsertCallUuidV7(), + (test, mark) => test.Db.Reducers.OnInsertCallUuidV7 += ctx => + { + RequireCommitted(ctx.Event.Status); + mark(); + }); -void RunGeneratedUuid(Action callReducer) +void RunGeneratedUuid(Action callReducer, Action registerReducerCallback) { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.OneUuid())); var inserted = false; + var reducerSeen = false; + registerReducerCallback(test, () => reducerSeen = true); test.Db.Db.OneUuid.OnInsert += (_, row) => { Require(row.U != Uuid.NIL, "Generated UUID was nil"); inserted = true; }; callReducer(test); - test.FrameTickUntil(() => inserted); + test.FrameTickUntil(() => inserted && reducerSeen); } void RunDeleteUuid() @@ -965,13 +997,20 @@ void RunInsertPrimitivesAsStrings() primitive.U.ToString(), }; var inserted = false; + var reducerSeen = false; + test.Db.Reducers.OnInsertPrimitivesAsStrings += (ctx, row) => + { + RequireCommitted(ctx.Event.Status); + RequireEveryPrimitiveStructEqual(row, primitive, "InsertPrimitivesAsStrings reducer callback saw wrong argument"); + reducerSeen = true; + }; test.Db.Db.VecString.OnInsert += (_, row) => { Require(row.S.SequenceEqual(expected), "Primitive string conversion did not round-trip"); inserted = true; }; test.Db.Reducers.InsertPrimitivesAsStrings(primitive); - test.FrameTickUntil(() => inserted); + test.FrameTickUntil(() => inserted && reducerSeen); } void RunReauth() @@ -1103,6 +1142,13 @@ void RunRowDeduplicationJoinRAndS() var pkInsert = false; var pkUpdate = false; var uniqueInsert = false; + var compositeReducerSeen = false; + test.Db.Reducers.OnInsertUniqueU32UpdatePkU32 += (ctx, n, uniqueData, pkData) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 42 && uniqueData == 0xbeef && pkData == 100, "Unexpected insert_unique_u32_update_pk_u32 args"); + compositeReducerSeen = true; + }; test.Db.Db.PkU32.OnInsert += (_, row) => { Require(row.N == 42 && row.Data == 50, "Unexpected pk_u32 insert"); @@ -1121,7 +1167,7 @@ void RunRowDeduplicationJoinRAndS() }; test.Db.Db.UniqueU32.OnDelete += (_, _) => throw new Exception("unique_u32 should not be deleted"); test.Db.Reducers.InsertPkU32(42, 50); - test.FrameTickUntil(() => pkInsert && pkUpdate && uniqueInsert); + test.FrameTickUntil(() => pkInsert && pkUpdate && uniqueInsert && compositeReducerSeen); } void RunRowDeduplicationRJoinSAndRJoinT() @@ -1134,9 +1180,16 @@ void RunRowDeduplicationRJoinSAndRJoinT() var pkInsert = false; var pkDelete = false; var pkTwoInsert = false; + var compositeReducerSeen = false; var uniqueInserts = 0; test.Db.Reducers.InsertUniqueU32(42, 0xbeef); test.FrameTickUntil(() => true); + test.Db.Reducers.OnDeletePkU32InsertPkU32Two += (ctx, n, data) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 42 && data == 0xbeef, "Unexpected delete_pk_u32_insert_pk_u32_two args"); + compositeReducerSeen = true; + }; test.Db.Db.PkU32.OnInsert += (_, row) => { Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32 insert"); @@ -1155,7 +1208,7 @@ void RunRowDeduplicationRJoinSAndRJoinT() }; test.Db.Db.UniqueU32.OnInsert += (_, _) => uniqueInserts++; test.Db.Reducers.InsertPkU32(42, 0xbeef); - test.FrameTickUntil(() => pkInsert && pkDelete && pkTwoInsert); + test.FrameTickUntil(() => pkInsert && pkDelete && pkTwoInsert && compositeReducerSeen); Require(uniqueInserts == 1, $"Expected exactly one deduplicated unique_u32 insert, got {uniqueInserts}"); } @@ -1176,6 +1229,27 @@ void RunLhsJoinUpdate(bool disjoint) var insertedRows = 0; var update1 = false; var update2 = false; + var insertPkReducers = 0; + var insertUniqueReducers = 0; + var updateReducers = 0; + test.Db.Reducers.OnInsertPkU32 += (ctx, n, data) => + { + RequireCommitted(ctx.Event.Status); + Require((n == 1 || n == 2) && data == 0, "Unexpected insert_pk_u32 args"); + insertPkReducers++; + }; + test.Db.Reducers.OnInsertUniqueU32 += (ctx, n, data) => + { + RequireCommitted(ctx.Event.Status); + Require((n == 1 && data == 3) || (n == 2 && data == 4), "Unexpected insert_unique_u32 args"); + insertUniqueReducers++; + }; + test.Db.Reducers.OnUpdatePkU32 += (ctx, n, data) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 2 && data is 0 or 1, "Unexpected update_pk_u32 args"); + updateReducers++; + }; test.Db.Db.PkU32.OnInsert += (_, row) => { if (row.N is 1 or 2) @@ -1199,9 +1273,9 @@ void RunLhsJoinUpdate(bool disjoint) test.Db.Reducers.InsertPkU32(2, 0); test.Db.Reducers.InsertUniqueU32(1, 3); test.Db.Reducers.InsertUniqueU32(2, 4); - test.FrameTickUntil(() => insertedRows == 2); + test.FrameTickUntil(() => insertedRows == 2 && insertPkReducers == 2 && insertUniqueReducers == 2); test.Db.Reducers.UpdatePkU32(2, 1); - test.FrameTickUntil(() => update1 && update2); + test.FrameTickUntil(() => update1 && update2 && updateReducers == 2); } void RunIntraQueryBagSemanticsForJoin() @@ -1211,9 +1285,24 @@ void RunIntraQueryBagSemanticsForJoin() "SELECT pk_u32.* FROM pk_u32 JOIN btree_u32 ON pk_u32.n = btree_u32.n"); var pkInserts = 0; var pkDeletes = 0; + var insertBtreeReducerSeen = false; + var insertPkBtreeReducerSeen = false; var firstBtreeDeleteReducerSeen = false; var secondBtreeDeleteReducerSeen = false; + test.Db.Reducers.OnInsertIntoBtreeU32 += (ctx, rows) => + { + RequireCommitted(ctx.Event.Status); + Require(rows.Count == 1 && rows[0].N == 0 && rows[0].Data == 0, "Unexpected insert_into_btree_u32 args"); + insertBtreeReducerSeen = true; + }; + test.Db.Reducers.OnInsertIntoPkBtreeU32 += (ctx, pkRows, btreeRows) => + { + RequireCommitted(ctx.Event.Status); + Require(pkRows.Count == 1 && pkRows[0].N == 0 && pkRows[0].Data == 0, "Unexpected insert_into_pk_btree_u32 pk args"); + Require(btreeRows.Count == 1 && btreeRows[0].N == 0 && btreeRows[0].Data == 1, "Unexpected insert_into_pk_btree_u32 btree args"); + insertPkBtreeReducerSeen = true; + }; test.Db.Db.PkU32.OnInsert += (_, row) => { Require(row.N == 0 && row.Data == 0, "Unexpected pk_u32 insert"); @@ -1248,7 +1337,7 @@ void RunIntraQueryBagSemanticsForJoin() test.Db.Reducers.DeleteFromBtreeU32(new() { new BTreeU32(0, 0) }); test.Db.Reducers.DeleteFromBtreeU32(new() { new BTreeU32(0, 1) }); - test.FrameTickUntil(() => firstBtreeDeleteReducerSeen && secondBtreeDeleteReducerSeen && pkDeletes == 1); + test.FrameTickUntil(() => insertBtreeReducerSeen && insertPkBtreeReducerSeen && firstBtreeDeleteReducerSeen && secondBtreeDeleteReducerSeen && pkDeletes == 1); Require(pkInserts == 1, $"Expected one pk_u32 insert, got {pkInserts}"); } @@ -1288,6 +1377,8 @@ void RunRlsSubscription() using var bob = ConnectAndSubscribeSql("SELECT * FROM users"); var aliceInserted = false; var bobInserted = false; + var aliceReducerSeen = false; + var bobReducerSeen = false; alice.Db.Db.Users.OnInsert += (_, row) => { Require(row.Name == "Alice" && row.Identity == alice.Identity, "Alice saw wrong RLS row"); @@ -1298,9 +1389,21 @@ void RunRlsSubscription() Require(row.Name == "Bob" && row.Identity == bob.Identity, "Bob saw wrong RLS row"); bobInserted = true; }; + alice.Db.Reducers.OnInsertUser += (ctx, name, identity) => + { + RequireCommitted(ctx.Event.Status); + Require(name == "Alice" && identity == alice.Identity, "Unexpected Alice insert_user args"); + aliceReducerSeen = true; + }; + bob.Db.Reducers.OnInsertUser += (ctx, name, identity) => + { + RequireCommitted(ctx.Event.Status); + Require(name == "Bob" && identity == bob.Identity, "Unexpected Bob insert_user args"); + bobReducerSeen = true; + }; alice.Db.Reducers.InsertUser("Alice", alice.Identity); bob.Db.Reducers.InsertUser("Bob", bob.Identity); - FrameTickUntil(new[] { alice, bob }, () => aliceInserted && bobInserted); + FrameTickUntil(new[] { alice, bob }, () => aliceInserted && bobInserted && aliceReducerSeen && bobReducerSeen); } void RunPkSimpleEnum() @@ -1308,6 +1411,20 @@ void RunPkSimpleEnum() using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkSimpleEnum())); var updated = false; var enumValue = SimpleEnum.Two; + var insertReducerSeen = false; + var updateReducerSeen = false; + test.Db.Reducers.OnInsertPkSimpleEnum += (ctx, a, data) => + { + RequireCommitted(ctx.Event.Status); + Require(a == enumValue && data == 42, "Unexpected insert_pk_simple_enum args"); + insertReducerSeen = true; + }; + test.Db.Reducers.OnUpdatePkSimpleEnum += (ctx, a, data) => + { + RequireCommitted(ctx.Event.Status); + Require(a == enumValue && data == 24, "Unexpected update_pk_simple_enum args"); + updateReducerSeen = true; + }; test.Db.Db.PkSimpleEnum.OnInsert += (_, row) => { Require(row.A == enumValue && row.Data == 42, "Unexpected pk_simple_enum insert"); @@ -1321,13 +1438,27 @@ void RunPkSimpleEnum() }; test.Db.Db.PkSimpleEnum.OnDelete += (_, _) => throw new Exception("pk_simple_enum should not be deleted"); test.Db.Reducers.InsertPkSimpleEnum(enumValue, 42); - test.FrameTickUntil(() => updated); + test.FrameTickUntil(() => updated && insertReducerSeen && updateReducerSeen); } void RunIndexedSimpleEnum() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.IndexedSimpleEnum())); var updated = false; + var insertReducerSeen = false; + var updateReducerSeen = false; + test.Db.Reducers.OnInsertIntoIndexedSimpleEnum += (ctx, n) => + { + RequireCommitted(ctx.Event.Status); + Require(n == SimpleEnum.Two, "Unexpected insert_into_indexed_simple_enum args"); + insertReducerSeen = true; + }; + test.Db.Reducers.OnUpdateIndexedSimpleEnum += (ctx, oldValue, newValue) => + { + RequireCommitted(ctx.Event.Status); + Require(oldValue == SimpleEnum.Two && newValue == SimpleEnum.One, "Unexpected update_indexed_simple_enum args"); + updateReducerSeen = true; + }; test.Db.Db.IndexedSimpleEnum.OnInsert += (_, row) => { if (row.N == SimpleEnum.Two) @@ -1340,7 +1471,7 @@ void RunIndexedSimpleEnum() } }; test.Db.Reducers.InsertIntoIndexedSimpleEnum(SimpleEnum.Two); - test.FrameTickUntil(() => updated); + test.FrameTickUntil(() => updated && insertReducerSeen && updateReducerSeen); } void RunOverlappingSubscriptions() @@ -1359,13 +1490,20 @@ void RunOverlappingSubscriptions() .Subscribe(new[] { "SELECT * FROM pk_u8 WHERE n < 100", "SELECT * FROM pk_u8 WHERE n > 0" }); test.FrameTickUntil(() => applied); var updated = false; + var updateReducerSeen = false; + test.Db.Reducers.OnUpdatePkU8 += (ctx, n, data) => + { + RequireCommitted(ctx.Event.Status); + Require(n == 1 && data == 1, "Unexpected update_pk_u8 args"); + updateReducerSeen = true; + }; test.Db.Db.PkU8.OnUpdate += (_, oldRow, newRow) => { Require(oldRow.N == 1 && oldRow.Data == 0 && newRow.N == 1 && newRow.Data == 1, "Overlapping update was wrong"); updated = true; }; test.Db.Reducers.UpdatePkU8(1, 1); - test.FrameTickUntil(() => updated); + test.FrameTickUntil(() => updated && updateReducerSeen); } void RunSortedUuidsInsert() From de90997f430ef619d91991156c1a1b68d1dd8478 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 14:02:13 -0500 Subject: [PATCH 10/18] Fix support for zero-width rows and add coverage for one_unit_struct --- sdks/csharp/src/CompressionHelpers.cs | 16 ++++++++-------- sdks/csharp/tests/sdk-test-client/Program.cs | 12 +++++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/sdks/csharp/src/CompressionHelpers.cs b/sdks/csharp/src/CompressionHelpers.cs index 832208938ed..90337167b9f 100644 --- a/sdks/csharp/src/CompressionHelpers.cs +++ b/sdks/csharp/src/CompressionHelpers.cs @@ -98,14 +98,14 @@ internal static ServerMessage DecompressDecodeMessage(byte[] bytes) internal static (BinaryReader reader, int rowCount) ParseRowList(BsatnRowList list) => ( new BinaryReader(new ListStream(list.RowsData)), - list.RowsData.Count == 0 - ? 0 - : list.SizeHint switch - { - RowSizeHint.FixedSize(var size) => list.RowsData.Count / size, - RowSizeHint.RowOffsets(var offsets) => offsets.Count, - _ => throw new NotImplementedException() - } + list.SizeHint switch + { + RowSizeHint.FixedSize(var size) => size == 0 + ? throw new InvalidOperationException("Fixed-size BSATN row list cannot have zero-sized rows") + : list.RowsData.Count / size, + RowSizeHint.RowOffsets(var offsets) => offsets.Count, + _ => throw new NotImplementedException() + } ); } } diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index 8f3379b7419..c07169edf89 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -851,6 +851,7 @@ void RunInsertStruct() var byteStruct = new ByteStruct { B = 99 }; var expected = new HashSet { + "one_unit_struct", "one_byte_struct", "one_every_primitive_struct", "one_every_vec_struct", @@ -860,11 +861,19 @@ void RunInsertStruct() "vec_every_vec_struct", }; var seen = new HashSet(); + var oneUnitStructReducerSeen = false; void Seen(string name) { Require(seen.Add(name), $"{name} callback fired more than once"); } + test.Db.Reducers.OnInsertOneUnitStruct += (ctx, s) => + { + RequireCommitted(ctx.Event.Status); + Require(s is not null, "InsertOneUnitStruct reducer callback saw null argument"); + oneUnitStructReducerSeen = true; + }; + test.Db.Db.OneUnitStruct.OnInsert += (_, row) => { Require(row.S is not null, "UnitStruct did not round-trip"); Seen("one_unit_struct"); }; test.Db.Db.OneByteStruct.OnInsert += (_, row) => { RequireByteStructEqual(row.S, byteStruct, "ByteStruct did not round-trip"); Seen("one_byte_struct"); }; test.Db.Db.OneEveryPrimitiveStruct.OnInsert += (_, row) => { RequireEveryPrimitiveStructEqual(row.S, primitive, "EveryPrimitiveStruct did not round-trip"); Seen("one_every_primitive_struct"); }; test.Db.Db.OneEveryVecStruct.OnInsert += (_, row) => { RequireEveryVecStructEqual(row.S, vec, "EveryVecStruct did not round-trip"); Seen("one_every_vec_struct"); }; @@ -873,6 +882,7 @@ void Seen(string name) test.Db.Db.VecEveryPrimitiveStruct.OnInsert += (_, row) => { RequireStructListEqual(row.S, new[] { primitive }, RequireEveryPrimitiveStructEqual, "VecEveryPrimitiveStruct did not round-trip"); Seen("vec_every_primitive_struct"); }; test.Db.Db.VecEveryVecStruct.OnInsert += (_, row) => { RequireStructListEqual(row.S, new[] { vec }, RequireEveryVecStructEqual, "VecEveryVecStruct did not round-trip"); Seen("vec_every_vec_struct"); }; + test.Db.Reducers.InsertOneUnitStruct(new UnitStruct()); test.Db.Reducers.InsertOneByteStruct(byteStruct); test.Db.Reducers.InsertOneEveryPrimitiveStruct(primitive); test.Db.Reducers.InsertOneEveryVecStruct(vec); @@ -881,7 +891,7 @@ void Seen(string name) test.Db.Reducers.InsertVecEveryPrimitiveStruct(new() { primitive }); test.Db.Reducers.InsertVecEveryVecStruct(new() { vec }); test.FrameTickUntil( - () => seen.SetEquals(expected), + () => seen.SetEquals(expected) && oneUnitStructReducerSeen, timeoutMessage: () => $"Timed out waiting for struct callbacks. Missing: {string.Join(", ", expected.Except(seen).OrderBy(name => name))}"); } From 5fddea670f2ccb7f44a42e521ec6aad4f8e8a02f Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 14:38:57 -0500 Subject: [PATCH 11/18] Add csharp prefix to all tests --- sdks/csharp/tests/sdk_csharp.rs | 128 ++++++++++++++++---------------- tools/ci/src/main.rs | 4 +- 2 files changed, 67 insertions(+), 65 deletions(-) diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs index e4e8d0648ec..cff4ce38900 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -60,386 +60,386 @@ fn make_procedural_view_pk_test(subcommand: &str) -> Test { #[test] #[serial(CsharpSdk)] -fn insert_primitive() { +fn csharp_insert_primitive() { make_test("insert-primitive").run(); } #[test] #[serial(CsharpSdk)] -fn subscribe_and_cancel() { +fn csharp_subscribe_and_cancel() { make_test("subscribe-and-cancel").run(); } #[test] #[serial(CsharpSdk)] -fn subscribe_and_unsubscribe() { +fn csharp_subscribe_and_unsubscribe() { make_test("subscribe-and-unsubscribe").run(); } #[test] #[serial(CsharpSdk)] -fn subscription_error_smoke_test() { +fn csharp_subscription_error_smoke_test() { make_test("subscription-error-smoke-test").run(); } #[test] #[serial(CsharpSdk)] -fn subscribe_all_select_star() { +fn csharp_subscribe_all_select_star() { make_test("subscribe-all-select-star").run(); } #[test] #[serial(CsharpSdk)] -fn delete_primitive() { +fn csharp_delete_primitive() { make_test("delete-primitive").run(); } #[test] #[serial(CsharpSdk)] -fn update_primitive() { +fn csharp_update_primitive() { make_test("update-primitive").run(); } #[test] #[serial(CsharpSdk)] -fn insert_identity() { +fn csharp_insert_identity() { make_test("insert-identity").run(); } #[test] #[serial(CsharpSdk)] -fn insert_caller_identity() { +fn csharp_insert_caller_identity() { make_test("insert-caller-identity").run(); } #[test] #[serial(CsharpSdk)] -fn delete_identity() { +fn csharp_delete_identity() { make_test("delete-identity").run(); } #[test] #[serial(CsharpSdk)] -fn update_identity() { +fn csharp_update_identity() { make_test("update-identity").run(); } #[test] #[serial(CsharpSdk)] -fn insert_connection_id() { +fn csharp_insert_connection_id() { make_test("insert-connection-id").run(); } #[test] #[serial(CsharpSdk)] -fn insert_caller_connection_id() { +fn csharp_insert_caller_connection_id() { make_test("insert-caller-connection-id").run(); } #[test] #[serial(CsharpSdk)] -fn delete_connection_id() { +fn csharp_delete_connection_id() { make_test("delete-connection-id").run(); } #[test] #[serial(CsharpSdk)] -fn update_connection_id() { +fn csharp_update_connection_id() { make_test("update-connection-id").run(); } #[test] #[serial(CsharpSdk)] -fn insert_timestamp() { +fn csharp_insert_timestamp() { make_test("insert-timestamp").run(); } #[test] #[serial(CsharpSdk)] -fn insert_call_timestamp() { +fn csharp_insert_call_timestamp() { make_test("insert-call-timestamp").run(); } #[test] #[serial(CsharpSdk)] -fn insert_uuid() { +fn csharp_insert_uuid() { make_test("insert-uuid").run(); } #[test] #[serial(CsharpSdk)] -fn insert_call_uuid_v4() { +fn csharp_insert_call_uuid_v4() { make_test("insert-call-uuid-v4").run(); } #[test] #[serial(CsharpSdk)] -fn insert_call_uuid_v7() { +fn csharp_insert_call_uuid_v7() { make_test("insert-call-uuid-v7").run(); } #[test] #[serial(CsharpSdk)] -fn delete_uuid() { +fn csharp_delete_uuid() { make_test("delete-uuid").run(); } #[test] #[serial(CsharpSdk)] -fn update_uuid() { +fn csharp_update_uuid() { make_test("update-uuid").run(); } #[test] #[serial(CsharpSdk)] -fn on_reducer() { +fn csharp_on_reducer() { make_test("on-reducer").run(); } #[test] #[serial(CsharpSdk)] -fn insert_vec() { +fn csharp_insert_vec() { make_test("insert-vec").run(); } #[test] #[serial(CsharpSdk)] -fn insert_option_some() { +fn csharp_insert_option_some() { make_test("insert-option-some").run(); } #[test] #[serial(CsharpSdk)] -fn insert_option_none() { +fn csharp_insert_option_none() { make_test("insert-option-none").run(); } #[test] #[serial(CsharpSdk)] -fn insert_struct() { +fn csharp_insert_struct() { make_test("insert-struct").run(); } #[test] #[serial(CsharpSdk)] -fn insert_simple_enum() { +fn csharp_insert_simple_enum() { make_test("insert-simple-enum").run(); } #[test] #[serial(CsharpSdk)] -fn insert_enum_with_payload() { +fn csharp_insert_enum_with_payload() { make_test("insert-enum-with-payload").run(); } #[test] #[serial(CsharpSdk)] -fn fail_reducer() { +fn csharp_fail_reducer() { make_test("fail-reducer").run(); } #[test] #[serial(CsharpSdk)] -fn insert_delete_large_table() { +fn csharp_insert_delete_large_table() { make_test("insert-delete-large-table").run(); } #[test] #[serial(CsharpSdk)] -fn insert_primitives_as_strings() { +fn csharp_insert_primitives_as_strings() { make_test("insert-primitives-as-strings").run(); } #[test] #[serial(CsharpSdk)] #[should_panic] -fn should_fail() { +fn csharp_should_fail() { make_test("should-fail").run(); } #[test] #[serial(CsharpSdk)] -fn reauth() { +fn csharp_reauth() { make_test("reauth").run(); } #[test] #[serial(CsharpSdk)] -fn reconnect_different_connection_id() { +fn csharp_reconnect_different_connection_id() { make_test("reconnect-different-connection-id").run(); } #[test] #[serial(CsharpSdk)] -fn caller_always_notified() { +fn csharp_caller_always_notified() { make_test("caller-always-notified").run(); } #[test] #[serial(CsharpSdk)] -fn caller_alice_receives_reducer_callback_but_not_bob() { +fn csharp_caller_alice_receives_reducer_callback_but_not_bob() { make_test("caller-alice-receives-reducer-callback-but-not-bob").run(); } #[test] #[serial(CsharpSdk)] -fn row_deduplication() { +fn csharp_row_deduplication() { make_test("row-deduplication").run(); } #[test] #[serial(CsharpSdk)] -fn row_deduplication_join_r_and_s() { +fn csharp_row_deduplication_join_r_and_s() { make_test("row-deduplication-join-r-and-s").run(); } #[test] #[serial(CsharpSdk)] -fn row_deduplication_r_join_s_and_r_joint() { +fn csharp_row_deduplication_r_join_s_and_r_joint() { make_test("row-deduplication-r-join-s-and-r-joint").run(); } #[test] #[serial(CsharpSdk)] -fn test_lhs_join_update() { +fn csharp_test_lhs_join_update() { make_test("test-lhs-join-update").run(); } #[test] #[serial(CsharpSdk)] -fn test_lhs_join_update_disjoint_queries() { +fn csharp_test_lhs_join_update_disjoint_queries() { make_test("test-lhs-join-update-disjoint-queries").run(); } #[test] #[serial(CsharpSdk)] -fn test_intra_query_bag_semantics_for_join() { +fn csharp_test_intra_query_bag_semantics_for_join() { make_test("test-intra-query-bag-semantics-for-join").run(); } #[test] #[serial(CsharpSdk)] -fn two_different_compression_algos() { +fn csharp_two_different_compression_algos() { make_test("two-different-compression-algos").run(); } #[test] #[serial(CsharpSdk)] -fn test_parameterized_subscription() { +fn csharp_test_parameterized_subscription() { make_test("test-parameterized-subscription").run(); } #[test] #[serial(CsharpSdk)] -fn test_rls_subscription() { +fn csharp_test_rls_subscription() { make_test("test-rls-subscription").run(); } #[test] #[serial(CsharpSdk)] -fn pk_simple_enum() { +fn csharp_pk_simple_enum() { make_test("pk-simple-enum").run(); } #[test] #[serial(CsharpSdk)] -fn indexed_simple_enum() { +fn csharp_indexed_simple_enum() { make_test("indexed-simple-enum").run(); } #[test] #[serial(CsharpSdk)] -fn overlapping_subscriptions() { +fn csharp_overlapping_subscriptions() { make_test("overlapping-subscriptions").run(); } #[test] #[serial(CsharpSdk)] -fn sorted_uuids_insert() { +fn csharp_sorted_uuids_insert() { make_test("sorted-uuids-insert").run(); } #[test] #[serial(CsharpSdk)] -fn procedure_return_values() { +fn csharp_procedure_return_values() { make_procedure_test("procedure-return-values").run(); } #[test] #[serial(CsharpSdk)] -fn procedure_observe_panic() { +fn csharp_procedure_observe_panic() { make_procedure_test("procedure-observe-panic").run(); } #[test] #[serial(CsharpSdk)] -fn insert_with_tx_commit() { +fn csharp_insert_with_tx_commit() { make_procedure_test("insert-with-tx-commit").run(); } #[test] #[serial(CsharpSdk)] -fn insert_with_tx_rollback() { +fn csharp_insert_with_tx_rollback() { make_procedure_test("insert-with-tx-rollback").run(); } #[test] #[serial(CsharpSdk)] -fn procedure_http_ok() { +fn csharp_procedure_http_ok() { make_procedure_test("procedure-http-ok").run(); } #[test] #[serial(CsharpSdk)] -fn procedure_http_err() { +fn csharp_procedure_http_err() { make_procedure_test("procedure-http-err").run(); } #[test] #[serial(CsharpSdk)] -fn schedule_procedure() { +fn csharp_schedule_procedure() { make_procedure_test("schedule-procedure").run(); } #[test] #[serial(CsharpSdk)] -fn view_pk_on_update() { +fn csharp_view_pk_on_update() { make_view_pk_test("view-pk-on-update").run(); } #[test] #[serial(CsharpSdk)] -fn view_pk_join_query_builder() { +fn csharp_view_pk_join_query_builder() { make_view_pk_test("view-pk-join-query-builder").run(); } #[test] #[serial(CsharpSdk)] -fn view_pk_semijoin_two_sender_views_query_builder() { +fn csharp_view_pk_semijoin_two_sender_views_query_builder() { make_view_pk_test("view-pk-semijoin-two-sender-views-query-builder").run(); } #[test] #[serial(CsharpSdk)] -fn sender_scoped_procedural_pk_view() { +fn csharp_sender_scoped_procedural_pk_view() { make_procedural_view_pk_test("sender-scoped-pk-view").run(); } #[test] #[serial(CsharpSdk)] -fn procedural_view_pk_left_semijoin() { +fn csharp_procedural_view_pk_left_semijoin() { make_procedural_view_pk_test("view-pk-left-semijoin").run(); } #[test] #[serial(CsharpSdk)] -fn procedural_view_pk_right_semijoin() { +fn csharp_procedural_view_pk_right_semijoin() { make_procedural_view_pk_test("view-pk-right-semijoin").run(); } #[test] #[serial(CsharpSdk)] -fn connect_disconnect_callbacks() { +fn csharp_connect_disconnect_callbacks() { Test::builder() .with_name("csharp-client-connect-disconnect-callbacks") .with_module("sdk-test-connect-disconnect-cs") diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 469d085bf5e..7755f77f8a6 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -588,7 +588,9 @@ fn main() -> Result<()> { "--", "--test-threads=2", "--skip", - "unreal" + "unreal", + "--skip", + "csharp" ) .run()?; // Bindings snapshot tests rely on the unstable feature, From c8fedadd69c4ff35a611389f0cc3facb0be9869f Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 15:35:04 -0500 Subject: [PATCH 12/18] Fix `should_fail` test and assertions being swallowed --- sdks/csharp/tests/sdk-test-client/Program.cs | 110 +++++++++++++------ sdks/csharp/tests/sdk_csharp.rs | 29 ++++- 2 files changed, 104 insertions(+), 35 deletions(-) diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/tests/sdk-test-client/Program.cs index c07169edf89..235959622ff 100644 --- a/sdks/csharp/tests/sdk-test-client/Program.cs +++ b/sdks/csharp/tests/sdk-test-client/Program.cs @@ -1105,12 +1105,13 @@ void RunRowDeduplication() using var test = ConnectAndSubscribeSql( "SELECT * FROM pk_u32 WHERE n < 100", "SELECT * FROM pk_u32 WHERE n < 200"); + var callbacks = new CallbackAssertions(); var ins24 = Once("insert 24"); var ins42 = Once("insert 42"); var del24 = Once("delete 24"); var upd42 = Once("update 42"); - test.Db.Db.PkU32.OnInsert += (_, row) => + test.Db.Db.PkU32.OnInsert += (_, row) => callbacks.Capture(() => { if (row.N == 24) { @@ -1126,21 +1127,21 @@ void RunRowDeduplication() { throw new Exception($"Unexpected pk_u32 insert {row.N}"); } - }; - test.Db.Db.PkU32.OnDelete += (_, row) => + }); + test.Db.Db.PkU32.OnDelete += (_, row) => callbacks.Capture(() => { Require(row.N == 24, "Only row 24 should be deleted"); del24.Invoke(); - }; - test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + }); + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => callbacks.Capture(() => { Require(oldRow.N == 42 && oldRow.Data == 0xbeef && newRow.N == 42 && newRow.Data == 0xfeeb, "Unexpected pk_u32 update"); upd42.Invoke(); - }; + }); test.Db.Reducers.InsertPkU32(24, 0xbeef); test.Db.Reducers.InsertPkU32(42, 0xbeef); - test.FrameTickUntil(() => ins24.Done && ins42.Done && del24.Done && upd42.Done); + test.FrameTickUntil(callbacks.Until(() => ins24.Done && ins42.Done && del24.Done && upd42.Done)); Require(test.Db.Db.PkU32.Count == 1, "Deduplicated cache should contain one row"); } @@ -1149,6 +1150,7 @@ void RunRowDeduplicationJoinRAndS() using var test = ConnectAndSubscribeSql( "SELECT * FROM pk_u32", "SELECT unique_u32.* FROM unique_u32 JOIN pk_u32 ON unique_u32.n = pk_u32.n"); + var callbacks = new CallbackAssertions(); var pkInsert = false; var pkUpdate = false; var uniqueInsert = false; @@ -1159,25 +1161,25 @@ void RunRowDeduplicationJoinRAndS() Require(n == 42 && uniqueData == 0xbeef && pkData == 100, "Unexpected insert_unique_u32_update_pk_u32 args"); compositeReducerSeen = true; }; - test.Db.Db.PkU32.OnInsert += (_, row) => + test.Db.Db.PkU32.OnInsert += (_, row) => callbacks.Capture(() => { Require(row.N == 42 && row.Data == 50, "Unexpected pk_u32 insert"); pkInsert = true; test.Db.Reducers.InsertUniqueU32UpdatePkU32(42, 0xbeef, 100); - }; - test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => + }); + test.Db.Db.PkU32.OnUpdate += (_, oldRow, newRow) => callbacks.Capture(() => { Require(oldRow.N == 42 && oldRow.Data == 50 && newRow.N == 42 && newRow.Data == 100, "Unexpected pk_u32 update"); pkUpdate = true; - }; - test.Db.Db.UniqueU32.OnInsert += (_, row) => + }); + test.Db.Db.UniqueU32.OnInsert += (_, row) => callbacks.Capture(() => { Require(row.N == 42 && row.Data == 0xbeef, "Unexpected unique_u32 insert"); uniqueInsert = true; - }; - test.Db.Db.UniqueU32.OnDelete += (_, _) => throw new Exception("unique_u32 should not be deleted"); + }); + test.Db.Db.UniqueU32.OnDelete += (_, _) => callbacks.Fail("unique_u32 should not be deleted"); test.Db.Reducers.InsertPkU32(42, 50); - test.FrameTickUntil(() => pkInsert && pkUpdate && uniqueInsert && compositeReducerSeen); + test.FrameTickUntil(callbacks.Until(() => pkInsert && pkUpdate && uniqueInsert && compositeReducerSeen)); } void RunRowDeduplicationRJoinSAndRJoinT() @@ -1187,6 +1189,7 @@ void RunRowDeduplicationRJoinSAndRJoinT() "SELECT * FROM pk_u32_two", "SELECT unique_u32.* FROM unique_u32 JOIN pk_u32 ON unique_u32.n = pk_u32.n", "SELECT unique_u32.* FROM unique_u32 JOIN pk_u32_two ON unique_u32.n = pk_u32_two.n"); + var callbacks = new CallbackAssertions(); var pkInsert = false; var pkDelete = false; var pkTwoInsert = false; @@ -1200,25 +1203,25 @@ void RunRowDeduplicationRJoinSAndRJoinT() Require(n == 42 && data == 0xbeef, "Unexpected delete_pk_u32_insert_pk_u32_two args"); compositeReducerSeen = true; }; - test.Db.Db.PkU32.OnInsert += (_, row) => + test.Db.Db.PkU32.OnInsert += (_, row) => callbacks.Capture(() => { Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32 insert"); pkInsert = true; test.Db.Reducers.DeletePkU32InsertPkU32Two(42, 0xbeef); - }; - test.Db.Db.PkU32.OnDelete += (_, row) => + }); + test.Db.Db.PkU32.OnDelete += (_, row) => callbacks.Capture(() => { Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32 delete"); pkDelete = true; - }; - test.Db.Db.PkU32Two.OnInsert += (_, row) => + }); + test.Db.Db.PkU32Two.OnInsert += (_, row) => callbacks.Capture(() => { Require(row.N == 42 && row.Data == 0xbeef, "Unexpected pk_u32_two insert"); pkTwoInsert = true; - }; + }); test.Db.Db.UniqueU32.OnInsert += (_, _) => uniqueInserts++; test.Db.Reducers.InsertPkU32(42, 0xbeef); - test.FrameTickUntil(() => pkInsert && pkDelete && pkTwoInsert && compositeReducerSeen); + test.FrameTickUntil(callbacks.Until(() => pkInsert && pkDelete && pkTwoInsert && compositeReducerSeen)); Require(uniqueInserts == 1, $"Expected exactly one deduplicated unique_u32 insert, got {uniqueInserts}"); } @@ -1293,6 +1296,7 @@ void RunIntraQueryBagSemanticsForJoin() using var test = ConnectAndSubscribeSql( "SELECT * FROM btree_u32", "SELECT pk_u32.* FROM pk_u32 JOIN btree_u32 ON pk_u32.n = btree_u32.n"); + var callbacks = new CallbackAssertions(); var pkInserts = 0; var pkDeletes = 0; var insertBtreeReducerSeen = false; @@ -1313,16 +1317,16 @@ void RunIntraQueryBagSemanticsForJoin() Require(btreeRows.Count == 1 && btreeRows[0].N == 0 && btreeRows[0].Data == 1, "Unexpected insert_into_pk_btree_u32 btree args"); insertPkBtreeReducerSeen = true; }; - test.Db.Db.PkU32.OnInsert += (_, row) => + test.Db.Db.PkU32.OnInsert += (_, row) => callbacks.Capture(() => { Require(row.N == 0 && row.Data == 0, "Unexpected pk_u32 insert"); pkInserts++; - }; - test.Db.Db.PkU32.OnDelete += (_, row) => + }); + test.Db.Db.PkU32.OnDelete += (_, row) => callbacks.Capture(() => { Require(row.N == 0 && row.Data == 0, "Unexpected pk_u32 delete"); pkDeletes++; - }; + }); test.Db.Reducers.OnDeleteFromBtreeU32 += (ctx, rows) => { RequireCommitted(ctx.Event.Status); @@ -1347,7 +1351,7 @@ void RunIntraQueryBagSemanticsForJoin() test.Db.Reducers.DeleteFromBtreeU32(new() { new BTreeU32(0, 0) }); test.Db.Reducers.DeleteFromBtreeU32(new() { new BTreeU32(0, 1) }); - test.FrameTickUntil(() => insertBtreeReducerSeen && insertPkBtreeReducerSeen && firstBtreeDeleteReducerSeen && secondBtreeDeleteReducerSeen && pkDeletes == 1); + test.FrameTickUntil(callbacks.Until(() => insertBtreeReducerSeen && insertPkBtreeReducerSeen && firstBtreeDeleteReducerSeen && secondBtreeDeleteReducerSeen && pkDeletes == 1)); Require(pkInserts == 1, $"Expected one pk_u32 insert, got {pkInserts}"); } @@ -1419,6 +1423,7 @@ void RunRlsSubscription() void RunPkSimpleEnum() { using var test = ConnectAndSubscribe(conn => conn.SubscriptionBuilder().AddQuery(qb => qb.From.PkSimpleEnum())); + var callbacks = new CallbackAssertions(); var updated = false; var enumValue = SimpleEnum.Two; var insertReducerSeen = false; @@ -1435,20 +1440,20 @@ void RunPkSimpleEnum() Require(a == enumValue && data == 24, "Unexpected update_pk_simple_enum args"); updateReducerSeen = true; }; - test.Db.Db.PkSimpleEnum.OnInsert += (_, row) => + test.Db.Db.PkSimpleEnum.OnInsert += (_, row) => callbacks.Capture(() => { Require(row.A == enumValue && row.Data == 42, "Unexpected pk_simple_enum insert"); test.Db.Reducers.UpdatePkSimpleEnum(enumValue, 24); - }; - test.Db.Db.PkSimpleEnum.OnUpdate += (_, oldRow, newRow) => + }); + test.Db.Db.PkSimpleEnum.OnUpdate += (_, oldRow, newRow) => callbacks.Capture(() => { Require(oldRow.A == enumValue && oldRow.Data == 42, "Unexpected old pk_simple_enum row"); Require(newRow.A == enumValue && newRow.Data == 24, "Unexpected new pk_simple_enum row"); updated = true; - }; - test.Db.Db.PkSimpleEnum.OnDelete += (_, _) => throw new Exception("pk_simple_enum should not be deleted"); + }); + test.Db.Db.PkSimpleEnum.OnDelete += (_, _) => callbacks.Fail("pk_simple_enum should not be deleted"); test.Db.Reducers.InsertPkSimpleEnum(enumValue, 42); - test.FrameTickUntil(() => updated && insertReducerSeen && updateReducerSeen); + test.FrameTickUntil(callbacks.Until(() => updated && insertReducerSeen && updateReducerSeen)); } void RunIndexedSimpleEnum() @@ -1990,6 +1995,45 @@ public OnceFlag(Action mark, Func done) public static implicit operator Action(OnceFlag flag) => flag.Invoke; } +sealed class CallbackAssertions +{ + private Exception? failure; + + public void Capture(Action assertion) + { + if (failure is not null) + { + return; + } + + try + { + assertion(); + } + catch (Exception e) + { + failure = e; + } + } + + public void Fail(string message) => Capture(() => throw new Exception(message)); + + public Func Until(Func isComplete) => + () => + { + ThrowIfAny(); + return isComplete(); + }; + + private void ThrowIfAny() + { + if (failure is not null) + { + throw new Exception("Table callback assertion failed", failure); + } + } +} + sealed class HarnessConnection : IDisposable { private readonly bool allowCleanDisconnect; diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/tests/sdk_csharp.rs index cff4ce38900..406ed60ecc5 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/tests/sdk_csharp.rs @@ -1,3 +1,5 @@ +use std::panic::{catch_unwind, take_hook, AssertUnwindSafe}; + use serial_test::serial; use spacetimedb_testing::sdk::Test; @@ -58,6 +60,27 @@ fn make_procedural_view_pk_test(subcommand: &str) -> Test { .build() } +fn run_expected_client_failure(test: Test, expected_message: &str) { + let panic_hook = take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let result = catch_unwind(AssertUnwindSafe(|| test.run())); + std::panic::set_hook(panic_hook); + + let panic = result.expect_err("Expected C# SDK harness client to fail"); + let message = if let Some(message) = panic.downcast_ref::() { + message.as_str() + } else if let Some(message) = panic.downcast_ref::<&str>() { + message + } else { + panic!("C# SDK harness failed with non-string panic"); + }; + + assert!( + message.contains("(running): Error running") && message.contains(expected_message), + "Expected C# SDK harness client failure containing {expected_message:?}, got:\n{message}" + ); +} + #[test] #[serial(CsharpSdk)] fn csharp_insert_primitive() { @@ -252,9 +275,11 @@ fn csharp_insert_primitives_as_strings() { #[test] #[serial(CsharpSdk)] -#[should_panic] fn csharp_should_fail() { - make_test("should-fail").run(); + run_expected_client_failure( + make_test("should-fail"), + "intentional failure for harness should_panic coverage", + ); } #[test] From e8082a9ca68959c451024738d6073806eef49540 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Thu, 6 Aug 2026 15:42:14 -0500 Subject: [PATCH 13/18] Fix whitespace --- sdks/csharp/src/Table.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/csharp/src/Table.cs b/sdks/csharp/src/Table.cs index 7cb56e31b57..b9ef9bd77af 100644 --- a/sdks/csharp/src/Table.cs +++ b/sdks/csharp/src/Table.cs @@ -103,7 +103,7 @@ public abstract class RemoteTableHandleBase : RemoteBase, IRe // and therefore avoids using reflection when initializing the row object. public abstract class IndexBase - // where Column : IEquatable // TODO: Revisit. Enums don't satisfy the `IEquatable` constraint. It shouldn't be needed though. + // where Column : IEquatable // TODO: Revisit. Enums don't satisfy the `IEquatable` constraint. It shouldn't be needed though. where Column : notnull { protected abstract Column GetKey(Row row); From c93430891b01a479532d5f22d74cd89898c5da3b Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 7 Aug 2026 11:42:35 -0500 Subject: [PATCH 14/18] Use a temp directory to install wasi-experimental --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddd504f2a6f..a9056c7f03d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1090,10 +1090,15 @@ jobs: dotnet workload config --update-mode manifests dotnet workload update --from-previous-sdk # Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module) - # Create temp global.json to target .NET 8 SDK for workload install - echo '{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}' > global.json - dotnet workload install wasi-experimental - rm global.json + # Create temp global.json to target .NET 8 SDK for workload install without + # overwriting the repository's .NET 10 global.json, which later C# harness + # tests rely on for SDK selection. + workload_dir="$(mktemp -d)" + echo '{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}' > "$workload_dir/global.json" + ( + cd "$workload_dir" + dotnet workload install wasi-experimental + ) - name: Override NuGet packages run: | From 7834baa07eb163fe4dd29e27af4c8465f65dc95d Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 7 Aug 2026 14:05:55 -0500 Subject: [PATCH 15/18] Ignore new tests in the Unity meta check --- sdks/csharp/.meta-check-ignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdks/csharp/.meta-check-ignore b/sdks/csharp/.meta-check-ignore index ab2c02f0ac5..10b7ac36359 100644 --- a/sdks/csharp/.meta-check-ignore +++ b/sdks/csharp/.meta-check-ignore @@ -1,2 +1,5 @@ unity-meta-skeleton~ unity-meta-skeleton~/** +Cargo.toml +tests +tests/** From 0081f1b515435d7057a478da28ae6ac5ae0a6d05 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Fri, 7 Aug 2026 14:11:03 -0500 Subject: [PATCH 16/18] Add ignored_file_path property to the Check Unity meta files step --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9056c7f03d..1934d828231 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -898,6 +898,7 @@ jobs: with: enable_pr_comment: ${{ github.event_name == 'pull_request' }} target_path: sdks/csharp + ignored_file_path: sdks/csharp/.meta-check-ignore env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" From fdf344d91e4c5d4923a003ecd7568aa3176bd2aa Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 10 Aug 2026 09:14:13 -0500 Subject: [PATCH 17/18] Move tests to test-harness~ --- .github/workflows/ci.yml | 1 - sdks/csharp/.meta-check-ignore | 3 --- sdks/csharp/Cargo.toml | 4 ++++ sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj | 2 +- sdks/csharp/SpacetimeDB.ClientSDK.csproj | 2 +- sdks/csharp/{tests => test-harness~}/build-client.sh | 0 .../connect-disconnect-client/Program.cs | 0 .../connect-disconnect-client.csproj | 0 .../procedural-view-pk-client/Program.cs | 0 .../procedural-view-pk-client.csproj | 0 .../procedure-client/Program.cs | 0 .../procedure-client/procedure-client.csproj | 0 .../sdk-test-client/Program.cs | 0 .../sdk-test-client/sdk-test-client.csproj | 0 sdks/csharp/{tests => test-harness~}/sdk_csharp.rs | 10 +++++----- .../{tests => test-harness~}/view-pk-client/Program.cs | 0 .../view-pk-client/view-pk-client.csproj | 0 17 files changed, 11 insertions(+), 11 deletions(-) rename sdks/csharp/{tests => test-harness~}/build-client.sh (100%) rename sdks/csharp/{tests => test-harness~}/connect-disconnect-client/Program.cs (100%) rename sdks/csharp/{tests => test-harness~}/connect-disconnect-client/connect-disconnect-client.csproj (100%) rename sdks/csharp/{tests => test-harness~}/procedural-view-pk-client/Program.cs (100%) rename sdks/csharp/{tests => test-harness~}/procedural-view-pk-client/procedural-view-pk-client.csproj (100%) rename sdks/csharp/{tests => test-harness~}/procedure-client/Program.cs (100%) rename sdks/csharp/{tests => test-harness~}/procedure-client/procedure-client.csproj (100%) rename sdks/csharp/{tests => test-harness~}/sdk-test-client/Program.cs (100%) rename sdks/csharp/{tests => test-harness~}/sdk-test-client/sdk-test-client.csproj (100%) rename sdks/csharp/{tests => test-harness~}/sdk_csharp.rs (98%) rename sdks/csharp/{tests => test-harness~}/view-pk-client/Program.cs (100%) rename sdks/csharp/{tests => test-harness~}/view-pk-client/view-pk-client.csproj (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1934d828231..a9056c7f03d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -898,7 +898,6 @@ jobs: with: enable_pr_comment: ${{ github.event_name == 'pull_request' }} target_path: sdks/csharp - ignored_file_path: sdks/csharp/.meta-check-ignore env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/sdks/csharp/.meta-check-ignore b/sdks/csharp/.meta-check-ignore index 10b7ac36359..ab2c02f0ac5 100644 --- a/sdks/csharp/.meta-check-ignore +++ b/sdks/csharp/.meta-check-ignore @@ -1,5 +1,2 @@ unity-meta-skeleton~ unity-meta-skeleton~/** -Cargo.toml -tests -tests/** diff --git a/sdks/csharp/Cargo.toml b/sdks/csharp/Cargo.toml index e97f1a04e40..b731ecbb6e8 100644 --- a/sdks/csharp/Cargo.toml +++ b/sdks/csharp/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true license-file = "LICENSE.txt" description = "A C# SDK test harness for SpacetimeDB clients" +[[test]] +name = "sdk_csharp" +path = "test-harness~/sdk_csharp.rs" + [dev-dependencies] spacetimedb-testing = { path = "../../crates/testing" } serial_test.workspace = true diff --git a/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj b/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj index 01c21891b3a..8039f5a7b17 100644 --- a/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj +++ b/sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj @@ -18,7 +18,7 @@ https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk 2.8.0 2.8.0 - $(DefaultItemExcludes);*~/**;tests/** + $(DefaultItemExcludes);*~/** obj~/godot/packages true $(DefineConstants);GODOT diff --git a/sdks/csharp/SpacetimeDB.ClientSDK.csproj b/sdks/csharp/SpacetimeDB.ClientSDK.csproj index cb92a34c759..6bc0a267e9c 100644 --- a/sdks/csharp/SpacetimeDB.ClientSDK.csproj +++ b/sdks/csharp/SpacetimeDB.ClientSDK.csproj @@ -18,7 +18,7 @@ https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk 2.8.0 2.8.0 - $(DefaultItemExcludes);*~/**;tests/** + $(DefaultItemExcludes);*~/** packages true diff --git a/sdks/csharp/tests/build-client.sh b/sdks/csharp/test-harness~/build-client.sh similarity index 100% rename from sdks/csharp/tests/build-client.sh rename to sdks/csharp/test-harness~/build-client.sh diff --git a/sdks/csharp/tests/connect-disconnect-client/Program.cs b/sdks/csharp/test-harness~/connect-disconnect-client/Program.cs similarity index 100% rename from sdks/csharp/tests/connect-disconnect-client/Program.cs rename to sdks/csharp/test-harness~/connect-disconnect-client/Program.cs diff --git a/sdks/csharp/tests/connect-disconnect-client/connect-disconnect-client.csproj b/sdks/csharp/test-harness~/connect-disconnect-client/connect-disconnect-client.csproj similarity index 100% rename from sdks/csharp/tests/connect-disconnect-client/connect-disconnect-client.csproj rename to sdks/csharp/test-harness~/connect-disconnect-client/connect-disconnect-client.csproj diff --git a/sdks/csharp/tests/procedural-view-pk-client/Program.cs b/sdks/csharp/test-harness~/procedural-view-pk-client/Program.cs similarity index 100% rename from sdks/csharp/tests/procedural-view-pk-client/Program.cs rename to sdks/csharp/test-harness~/procedural-view-pk-client/Program.cs diff --git a/sdks/csharp/tests/procedural-view-pk-client/procedural-view-pk-client.csproj b/sdks/csharp/test-harness~/procedural-view-pk-client/procedural-view-pk-client.csproj similarity index 100% rename from sdks/csharp/tests/procedural-view-pk-client/procedural-view-pk-client.csproj rename to sdks/csharp/test-harness~/procedural-view-pk-client/procedural-view-pk-client.csproj diff --git a/sdks/csharp/tests/procedure-client/Program.cs b/sdks/csharp/test-harness~/procedure-client/Program.cs similarity index 100% rename from sdks/csharp/tests/procedure-client/Program.cs rename to sdks/csharp/test-harness~/procedure-client/Program.cs diff --git a/sdks/csharp/tests/procedure-client/procedure-client.csproj b/sdks/csharp/test-harness~/procedure-client/procedure-client.csproj similarity index 100% rename from sdks/csharp/tests/procedure-client/procedure-client.csproj rename to sdks/csharp/test-harness~/procedure-client/procedure-client.csproj diff --git a/sdks/csharp/tests/sdk-test-client/Program.cs b/sdks/csharp/test-harness~/sdk-test-client/Program.cs similarity index 100% rename from sdks/csharp/tests/sdk-test-client/Program.cs rename to sdks/csharp/test-harness~/sdk-test-client/Program.cs diff --git a/sdks/csharp/tests/sdk-test-client/sdk-test-client.csproj b/sdks/csharp/test-harness~/sdk-test-client/sdk-test-client.csproj similarity index 100% rename from sdks/csharp/tests/sdk-test-client/sdk-test-client.csproj rename to sdks/csharp/test-harness~/sdk-test-client/sdk-test-client.csproj diff --git a/sdks/csharp/tests/sdk_csharp.rs b/sdks/csharp/test-harness~/sdk_csharp.rs similarity index 98% rename from sdks/csharp/tests/sdk_csharp.rs rename to sdks/csharp/test-harness~/sdk_csharp.rs index 406ed60ecc5..dd69347f2ef 100644 --- a/sdks/csharp/tests/sdk_csharp.rs +++ b/sdks/csharp/test-harness~/sdk_csharp.rs @@ -3,11 +3,11 @@ use std::panic::{catch_unwind, take_hook, AssertUnwindSafe}; use serial_test::serial; use spacetimedb_testing::sdk::Test; -const TEST_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/sdk-test-client"); -const CONNECT_DISCONNECT_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/connect-disconnect-client"); -const PROCEDURE_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/procedure-client"); -const VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/view-pk-client"); -const PROCEDURAL_VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/procedural-view-pk-client"); +const TEST_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/sdk-test-client"); +const CONNECT_DISCONNECT_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/connect-disconnect-client"); +const PROCEDURE_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/procedure-client"); +const VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/view-pk-client"); +const PROCEDURAL_VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/procedural-view-pk-client"); fn make_test(subcommand: &str) -> Test { Test::builder() diff --git a/sdks/csharp/tests/view-pk-client/Program.cs b/sdks/csharp/test-harness~/view-pk-client/Program.cs similarity index 100% rename from sdks/csharp/tests/view-pk-client/Program.cs rename to sdks/csharp/test-harness~/view-pk-client/Program.cs diff --git a/sdks/csharp/tests/view-pk-client/view-pk-client.csproj b/sdks/csharp/test-harness~/view-pk-client/view-pk-client.csproj similarity index 100% rename from sdks/csharp/tests/view-pk-client/view-pk-client.csproj rename to sdks/csharp/test-harness~/view-pk-client/view-pk-client.csproj From 8a89ff9432cd6ba1974e1c6d7f65d8622ad75697 Mon Sep 17 00:00:00 2001 From: Lisandro Crespo Date: Mon, 10 Aug 2026 09:35:54 -0500 Subject: [PATCH 18/18] Move Cargo.toml --- Cargo.toml | 2 +- sdks/csharp/{ => test-harness~}/Cargo.toml | 6 +++--- sdks/csharp/test-harness~/sdk_csharp.rs | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) rename sdks/csharp/{ => test-harness~}/Cargo.toml (66%) diff --git a/Cargo.toml b/Cargo.toml index beb34d2103b..48f48dbb4ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ members = [ "crates/sats", "crates/schema", "crates/smoketests", - "sdks/csharp", + "sdks/csharp/test-harness~", "sdks/rust", "sdks/unreal", "crates/snapshot", diff --git a/sdks/csharp/Cargo.toml b/sdks/csharp/test-harness~/Cargo.toml similarity index 66% rename from sdks/csharp/Cargo.toml rename to sdks/csharp/test-harness~/Cargo.toml index b731ecbb6e8..0586b7b28b2 100644 --- a/sdks/csharp/Cargo.toml +++ b/sdks/csharp/test-harness~/Cargo.toml @@ -2,15 +2,15 @@ name = "sdk-csharp-test-harness" version.workspace = true edition.workspace = true -license-file = "LICENSE.txt" +license-file = "../../../licenses/apache2.txt" description = "A C# SDK test harness for SpacetimeDB clients" [[test]] name = "sdk_csharp" -path = "test-harness~/sdk_csharp.rs" +path = "sdk_csharp.rs" [dev-dependencies] -spacetimedb-testing = { path = "../../crates/testing" } +spacetimedb-testing = { path = "../../../crates/testing" } serial_test.workspace = true [lints] diff --git a/sdks/csharp/test-harness~/sdk_csharp.rs b/sdks/csharp/test-harness~/sdk_csharp.rs index dd69347f2ef..0c6930b85a8 100644 --- a/sdks/csharp/test-harness~/sdk_csharp.rs +++ b/sdks/csharp/test-harness~/sdk_csharp.rs @@ -3,11 +3,11 @@ use std::panic::{catch_unwind, take_hook, AssertUnwindSafe}; use serial_test::serial; use spacetimedb_testing::sdk::Test; -const TEST_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/sdk-test-client"); -const CONNECT_DISCONNECT_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/connect-disconnect-client"); -const PROCEDURE_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/procedure-client"); -const VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/view-pk-client"); -const PROCEDURAL_VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/test-harness~/procedural-view-pk-client"); +const TEST_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/sdk-test-client"); +const CONNECT_DISCONNECT_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/connect-disconnect-client"); +const PROCEDURE_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/procedure-client"); +const VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/view-pk-client"); +const PROCEDURAL_VIEW_PK_CLIENT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/procedural-view-pk-client"); fn make_test(subcommand: &str) -> Test { Test::builder()