Files
voice-cat/clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs
Talon 6b7f06a282 feat(clients): expose all channel codec params + guest nickname everywhere
Channel create/edit UIs only surfaced a subset of the core's vc_audio_config,
and DRED was exposed nowhere. While adding it, found a latent ABI mismatch:
both Swift AudioConfig and the C# VcAudioConfigNative blittable struct were one
int short of the native vc_audio_config (missing the trailing `dred`), so native
read past the managed struct in vc_create_channel/vc_edit_channel.

- core marshaling: thread `dred` through Swift (Models/Marshaling/toNative) and
  C# (Structs/Models/Marshaling/VoiceCatClient) -- fixes the ABI gap + enables it
- windows: add the one missing DRED checkbox to ChannelEditDialog
- macos: ChannelEditSheet now exposes application, sample rate, packet loss,
  complexity, and DRED (was stereo/bitrate/frame/FEC/DTX only)
- ios: rebuild ChannelEditView into a full create+edit form (all params); add
  SessionState.editChannel + an admin Edit swipe action (iOS had no edit UI)
- guest nickname: add a dedicated `nickname` to SavedServer on macOS+iOS
  (backward-compatible Codable), shown in Guest mode, wired into the guest auth
  path -- guests could not set a display name on either before (only Windows)

Verified: macOS + iOS (sim, arm64) xcodebuild BUILD SUCCEEDED; core ctest 22/23
(only external_pcm aborts on a pre-existing shutdown mutex race; no C++ changed).
2026-06-21 04:03:50 +02:00

396 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Text.RegularExpressions;
using VoiceCat.Interop;
namespace VoiceCat.Interop.Tests;
/// <summary>
/// Exercises the full connect -> TOFU event -> confirm -> guest auth -> list channels flow
/// purely through the P/Invoke layer (NativeMethods/VoiceCatClient), against a real
/// `voicecat-server.exe` (the same binary the C++ ctest suite uses) — this is the same flow
/// tests/test_tofu_flow.cpp already proves at the C++ level, now proven reachable through
/// P/Invoke specifically: marshaling bugs, callback-lifetime bugs, and calling-convention
/// mistakes are all P/Invoke-specific failure modes ctest alone cannot catch.
/// </summary>
public sealed class VoiceCatClientSmokeTests : IDisposable
{
private readonly string _tempDir;
private readonly Process _server;
private readonly ushort _port;
public VoiceCatClientSmokeTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "vc_csharp_smoke_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
string serverExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-server.exe");
Assert.True(File.Exists(serverExe),
$"voicecat-server.exe not found at '{serverExe}' — build the m1-dev preset first " +
"(cmake --preset m1-dev && cmake --build --preset m1-dev).");
var psi = new ProcessStartInfo(serverExe)
{
Arguments = $"--port 0 --data-dir \"{_tempDir}\" --name CSharpSmokeTest",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
_server = Process.Start(psi) ?? throw new InvalidOperationException("failed to start voicecat-server.exe");
// "[voicecat-server] <name> — TCP :<port> UDP :<port>" — see server/src/server.cpp.
// ReadLineAsync()+timeout (not a bare blocking ReadLine() in a deadline loop) so a
// server that never prints anything (crash, hang) can't hang this constructor forever
// — the deadline check must apply to the read itself, not just the loop around it.
ushort? port = null;
var deadline = DateTime.UtcNow.AddSeconds(10);
while (port is null && DateTime.UtcNow < deadline)
{
var readTask = _server.StandardOutput.ReadLineAsync();
var remaining = deadline - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero || !readTask.Wait(remaining)) break;
string? line = readTask.Result;
if (line is null) break;
var m = Regex.Match(line, @"TCP :(\d+)");
if (m.Success) port = ushort.Parse(m.Groups[1].Value);
}
Assert.True(port is not null, "voicecat-server.exe did not report a bound TCP port within 10s.");
_port = port!.Value;
// M5: provision a known admin account so we can exercise moderation wrappers end-to-end.
string adminExe = Path.Combine(FindRepoRoot(), "build", "m1-dev", "bin", "voicecat-admin.exe");
Assert.True(File.Exists(adminExe), "voicecat-admin.exe not found — build the m1-dev preset.");
var adminPsi = new ProcessStartInfo(adminExe)
{
Arguments = $"--data-dir \"{_tempDir}\" account add admin2 --admin --password testpassword123",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using (var adminProc = Process.Start(adminPsi) ?? throw new InvalidOperationException("failed to start voicecat-admin.exe"))
{
Assert.True(adminProc.WaitForExit(10000), "voicecat-admin.exe did not exit within 10s.");
Assert.Equal(0, adminProc.ExitCode);
}
}
public void Dispose()
{
try { if (!_server.HasExited) _server.Kill(entireProcessTree: true); } catch { /* best effort */ }
try { Directory.Delete(_tempDir, recursive: true); } catch { /* best effort */ }
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "CMakePresets.json")))
dir = dir.Parent;
return dir?.FullName ?? throw new InvalidOperationException("Could not find repo root (CMakePresets.json) above " + AppContext.BaseDirectory);
}
private static bool PumpUntil(VoiceCatClient client, Func<bool> predicate, int timeoutMs)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
client.PumpEvents();
if (predicate()) return true;
Thread.Sleep(20);
}
client.PumpEvents();
return predicate();
}
[Fact]
public void VersionString_IsNonEmpty()
{
Assert.False(string.IsNullOrEmpty(VoiceCatClient.VersionString));
}
[Fact]
public void Connect_Tofu_Auth_ListChannels_RoundTrips()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-smoke", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpSmoke"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000),
"did not receive VC_EVENT_SERVER_IDENTITY");
var identityEvent = events.First(e => e.Type == VcEventType.ServerIdentity);
Assert.Equal((uint)VcTofuStatus.FirstConnect, identityEvent.U32a);
Assert.NotNull(identityEvent.Text);
Assert.Equal(64, identityEvent.Text!.Length); // SHA-256 hex, no separators
// Auth must NOT complete before the identity is confirmed.
Assert.False(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 800));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000),
"did not receive VC_EVENT_AUTH_RESULT after confirming identity");
var authEvent = events.First(e => e.Type == VcEventType.AuthResult);
Assert.Equal(VcResult.Ok, authEvent.Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000),
"did not receive VC_EVENT_CHANNEL_LIST");
var channels = client.ListChannels();
Assert.Contains(channels, c => c.Id == 1 && c.Name == "Lobby");
// M5: permissions getter round-trip.
var perms = client.GetPermissions();
Assert.False(perms.IsAdmin);
Assert.False(perms.CanKick);
// M5: moderation request wrappers queue without error. As a guest, account listing
// is rejected by the server with a GenericResult, which proves the wrapper path works
// end-to-end and that the new event type is delivered through P/Invoke.
Assert.Equal(VcResult.Ok, client.RequestAccountList());
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult), 3000),
"did not receive VC_EVENT_GENERIC_RESULT for guest ListAccounts");
var generic = events.First(e => e.Type == VcEventType.GenericResult);
Assert.Equal(VcResult.PermissionDenied, generic.Result);
client.Disconnect();
}
[Fact]
public void Admin_ChannelCrud_AccountCrud_RoundTrips()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-admin", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_admin.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateUser("admin2", "testpassword123"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
var perms = client.GetPermissions();
Assert.True(perms.IsAdmin || perms.CanAdminAccounts);
// Channel CRUD
Assert.Equal(VcResult.Ok, client.CreateChannel(new ChannelEditInfo(
Id: 0,
ParentId: 0,
Name: "CSharp Test Channel",
Topic: "Created by C# smoke test",
PasswordProtected: false,
Password: null,
MaxUsers: 42,
SortOrder: 0,
Audio: new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateChannel did not succeed");
var channels = client.ListChannels();
var created = channels.FirstOrDefault(c => c.Name == "CSharp Test Channel");
Assert.NotNull(created);
Assert.Equal("Created by C# smoke test", created.Topic);
Assert.True(created.PasswordProtected == false);
Assert.Equal(VcResult.Ok, client.EditChannel(new ChannelEditInfo(
created.Id,
created.ParentId,
created.Name,
"Updated topic",
created.PasswordProtected,
null,
100,
0,
new AudioConfigInfo(0, true, 48000, 64000, 20, 1, true, 5, false, 10, false))));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"EditChannel did not succeed");
Assert.Equal(VcResult.Ok, client.DeleteChannel(created.Id));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"DeleteChannel did not succeed");
// Account CRUD
Assert.Equal(VcResult.Ok, client.CreateAccount("csharp_smoke_user", "initialpw"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"CreateAccount did not succeed");
Assert.Equal(VcResult.Ok, client.RequestAccountList());
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AccountList), 3000));
var accounts = client.ListAccounts();
Assert.Contains(accounts, a => a.Username == "csharp_smoke_user");
Assert.Equal(VcResult.Ok, client.ResetPassword("csharp_smoke_user", "newpw123"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"ResetPassword did not succeed");
Assert.Equal(VcResult.Ok, client.DeleteAccount("csharp_smoke_user"));
events.Clear();
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.GenericResult && e.Result == VcResult.Ok), 3000),
"DeleteAccount did not succeed");
client.Disconnect();
}
/// <summary>
/// Screen-audio (SCREEN_AUDIO) stream start/stop through the P/Invoke layer. The core's
/// WASAPI loopback path (VOICECAT_HAS_LOOPBACK) captures the default render endpoint; the
/// StreamAnnounce succeeds regardless of whether the loopback device actually initializes
/// on a headless box, so this test passes in CI while still exercising the full
/// StartStream -> StreamStarted -> StopStream -> StreamStopped path through P/Invoke.
/// See docs/voice.md §9 and MainForm's BtnScreenShareToggle_Click.
/// </summary>
[Fact]
public void ScreenAudioStream_Starts_And_Stops()
{
var events = new List<VoiceCatEvent>();
using var client = new VoiceCatClient("vc-csharp-screen", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_screen.txt"));
client.EventReceived += events.Add;
Assert.Equal(VcResult.Ok, client.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, client.AuthenticateGuest("CSharpScreen"));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, client.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, events.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(client, () => events.Any(e => e.Type == VcEventType.ChannelList), 3000));
// Give the async UDP binding handshake a moment to land before announcing a stream
// (mirrors vccli's 500ms sleep after auth).
Thread.Sleep(500);
var (startResult, streamId) = client.StartStream(VcStreamKind.ScreenAudio, "Desktop audio");
Assert.Equal(VcResult.Ok, startResult);
Assert.True(streamId != 0, "StreamId should be non-zero on success");
// The core emits VC_EVENT_STREAM_STARTED for the local client too (client.cpp
// handle_stream_announce_result), so we see our own screen-audio stream start.
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId), 5000),
"did not receive VC_EVENT_STREAM_STARTED for screen-audio stream");
Assert.Equal(VcResult.Ok, client.StopStream(streamId));
Assert.True(PumpUntil(client,
() => events.Any(e => e.Type == VcEventType.StreamStopped && e.StreamId == streamId), 5000),
"did not receive VC_EVENT_STREAM_STOPPED for screen-audio stream");
client.Disconnect();
}
/// <summary>
/// Per-stream receive-side controls (gain/mute/NR) round-trip through P/Invoke: two
/// clients in a channel, one publishes a MIC stream, the other SetRemoteStream's it then
/// GetRemoteStream's it back. Catches P/Invoke-specific marshaling bugs in
/// VcRemoteStreamStateNative (field order, bool-from-int, float precision) that the C++
/// ctest (test_m3_multistream) cannot. See docs/voice.md §1, §10.
/// </summary>
[Fact]
public void PerStream_RecvControls_RoundTrip_Through_PInvoke()
{
var eventsA = new List<VoiceCatEvent>();
var eventsB = new List<VoiceCatEvent>();
using var a = new VoiceCatClient("vc-csharp-mix-a", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_a.txt"));
using var b = new VoiceCatClient("vc-csharp-mix-b", "0.1", VcLogLevel.Off,
tofuStorePath: Path.Combine(_tempDir, "tofu_pins_mix_b.txt"));
a.EventReceived += eventsA.Add;
b.EventReceived += eventsB.Add;
// Connect + auth A first, then B — staggering avoids concurrent TLS handshakes against
// the same server (mirrors the C++ test_m3_multistream harness, which connects A to
// completion before starting B).
Assert.Equal(VcResult.Ok, a.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, a.AuthenticateGuest("CSharpMixA"));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, a.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, eventsA.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.ChannelList), 3000));
Assert.Equal(VcResult.Ok, b.Connect("127.0.0.1", _port));
Assert.Equal(VcResult.Ok, b.AuthenticateGuest("CSharpMixB"));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ServerIdentity), 5000));
Assert.Equal(VcResult.Ok, b.ConfirmServerIdentity(accept: true));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.AuthResult), 5000));
Assert.Equal(VcResult.Ok, eventsB.First(e => e.Type == VcEventType.AuthResult).Result);
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.ChannelList), 3000));
// Both join Lobby (channel 1) so voice relays between them.
Assert.Equal(VcResult.Ok, a.JoinChannel(1, null));
Assert.True(PumpUntil(a, () => eventsA.Any(e => e.Type == VcEventType.JoinResult), 5000),
"A did not receive VC_EVENT_JOIN_RESULT");
Assert.Equal(VcResult.Ok, b.JoinChannel(1, null));
Assert.True(PumpUntil(b, () => eventsB.Any(e => e.Type == VcEventType.JoinResult), 5000),
"B did not receive VC_EVENT_JOIN_RESULT");
// UDP binding handshake is async; give it a moment (mirrors ScreenAudio test).
Thread.Sleep(500);
// A publishes a MIC stream.
var (startResult, streamId) = a.StartStream(VcStreamKind.Mic, "mix-test-mic");
Assert.Equal(VcResult.Ok, startResult);
Assert.True(streamId != 0);
// B sees A's stream and can enumerate it.
uint aUid = 0;
Assert.True(PumpUntil(b, () =>
{
return eventsB.Any(e => e.Type == VcEventType.StreamStarted && e.StreamId == streamId);
}, 5000), "B did not see A's STREAM_STARTED");
// Resolve A's user id from B's user list.
Assert.True(PumpUntil(b, () =>
{
aUid = b.ListUsers().FirstOrDefault(u => u.Nickname == "CSharpMixA")?.Id ?? 0;
return aUid != 0;
}, 3000), "could not resolve A's user id on B");
Assert.True(aUid != 0);
Assert.True(PumpUntil(b, () => b.ListUserStreams(aUid).Any(s => s.StreamId == streamId), 3000),
"B could not enumerate A's stream");
var bStreams = b.ListUserStreams(aUid);
Assert.Contains(bStreams, s => s.StreamId == streamId && s.Kind == VcStreamKind.Mic);
// Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
var (r0, st0) = b.GetRemoteStream(aUid, streamId);
Assert.Equal(VcResult.Ok, r0);
Assert.NotNull(st0);
Assert.Equal(1.0f, st0!.Gain);
Assert.False(st0.Muted);
Assert.False(st0.NoiseReduction);
// B turns A down to 0.4×, mutes, and enables NR — then reads it back.
Assert.Equal(VcResult.Ok, b.SetRemoteStream(aUid, streamId, 0.4f, muted: true, noiseReduction: true));
var (r1, st1) = b.GetRemoteStream(aUid, streamId);
Assert.Equal(VcResult.Ok, r1);
Assert.NotNull(st1);
Assert.Equal(0.4f, st1!.Gain);
Assert.True(st1.Muted);
Assert.True(st1.NoiseReduction);
// Unknown stream id on a known user -> INVALID_ARG.
var (rBad, stBad) = b.GetRemoteStream(aUid, 0xDEADBEEF);
Assert.Equal(VcResult.InvalidArg, rBad);
Assert.Null(stBad);
a.Disconnect();
b.Disconnect();
}
}