Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
345 lines
13 KiB
C++
345 lines
13 KiB
C++
/*
|
|
* test_channel_user_list_abi — M4 channel/user/stream snapshot getters.
|
|
*
|
|
* Covers the new pull-based ABI surface (voicecat.h): vc_list_channels, vc_list_users,
|
|
* vc_list_user_streams, and the new VC_EVENT_JOIN_RESULT feedback for vc_join_channel.
|
|
* Mirrors test_vad_ptt_devices.cpp's approach (real vc_client instances against a real
|
|
* in-process server, not raw sockets).
|
|
*
|
|
* 1. Pre-data: a freshly created (not yet connected) client's getters return VC_OK,
|
|
* count=0 — never an error just because nothing has arrived yet.
|
|
* 2. After two guests connect and auth: vc_list_channels reflects the server's real
|
|
* channel config (this is the regression test for the SessionModel field-population
|
|
* fix — parent_id/password_protected/max_users were silently dropped before);
|
|
* vc_list_users on either client includes both users with correct nickname/channel_id.
|
|
* 3. After A starts a MIC stream: B's vc_list_user_streams(A's user_id) shows it, with the
|
|
* same stream_id as A's own VC_EVENT_STREAM_STARTED.
|
|
* 4. vc_list_user_streams with an unknown user_id returns VC_ERR_INVALID_ARG.
|
|
* 5. vc_join_channel's result arrives via VC_EVENT_JOIN_RESULT — success (re-joining the
|
|
* channel already in) and failure (an unknown channel id).
|
|
* 6. Every vc_free_*_list is idempotent (safe to call twice).
|
|
*/
|
|
#include <cstdio>
|
|
|
|
#ifdef VOICECAT_HAS_NET
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "voicecat.h"
|
|
#include "server.h"
|
|
|
|
// ── Event tracking ────────────────────────────────────────────────────────────
|
|
|
|
struct StreamEvent {
|
|
bool started;
|
|
uint32_t user_id;
|
|
uint32_t stream_id;
|
|
};
|
|
|
|
struct JoinResult {
|
|
bool ok;
|
|
uint32_t channel_id;
|
|
std::string error;
|
|
};
|
|
|
|
struct EventStore {
|
|
std::mutex mu;
|
|
std::condition_variable cv;
|
|
|
|
bool auth_ok{false};
|
|
uint32_t self_user_id{0};
|
|
bool channel_list_received{false};
|
|
std::vector<StreamEvent> stream_events;
|
|
std::vector<JoinResult> join_results;
|
|
bool voice_subscribed{false};
|
|
|
|
const char* label{nullptr};
|
|
vc_client* client{nullptr};
|
|
};
|
|
|
|
static void on_event(void* user, const vc_event* ev) {
|
|
auto* s = static_cast<EventStore*>(user);
|
|
std::lock_guard lk(s->mu);
|
|
switch (ev->type) {
|
|
case VC_EVENT_SERVER_IDENTITY:
|
|
vc_confirm_server_identity(s->client, 1);
|
|
break;
|
|
case VC_EVENT_AUTH_RESULT:
|
|
s->auth_ok = (ev->result == VC_OK);
|
|
s->self_user_id = ev->user_id;
|
|
break;
|
|
case VC_EVENT_CHANNEL_LIST:
|
|
s->channel_list_received = true;
|
|
break;
|
|
case VC_EVENT_VOICE_STATE:
|
|
s->voice_subscribed = (ev->u32a == 1);
|
|
break;
|
|
case VC_EVENT_STREAM_STARTED:
|
|
s->stream_events.push_back({true, ev->user_id, ev->stream_id});
|
|
break;
|
|
case VC_EVENT_STREAM_STOPPED:
|
|
s->stream_events.push_back({false, ev->user_id, ev->stream_id});
|
|
break;
|
|
case VC_EVENT_JOIN_RESULT:
|
|
s->join_results.push_back(
|
|
{ev->result == VC_OK, ev->channel_id, ev->text ? ev->text : ""});
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
s->cv.notify_all();
|
|
}
|
|
|
|
template <typename Pred>
|
|
static bool wait_for(EventStore& s, Pred pred, int timeout_ms) {
|
|
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
|
std::unique_lock lk(s.mu);
|
|
return s.cv.wait_until(lk, deadline, [&] { return pred(s); });
|
|
}
|
|
|
|
// ── Test harness ──────────────────────────────────────────────────────────────
|
|
|
|
static int g_failures = 0;
|
|
#define CHECK(cond) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \
|
|
++g_failures; \
|
|
} \
|
|
} while (0)
|
|
|
|
// ── 1. Pre-data: getters never error just because nothing has arrived yet ──────
|
|
static void test_pre_data_empty() {
|
|
vc_config cfg{"test-predata", "0.1", VC_LOG_OFF};
|
|
vc_callbacks cb{};
|
|
vc_client* c = vc_client_create(&cfg, cb);
|
|
CHECK(c != nullptr);
|
|
|
|
vc_channel_list cl{};
|
|
CHECK(vc_list_channels(c, &cl) == VC_OK);
|
|
CHECK(cl.count == 0);
|
|
vc_free_channel_list(&cl);
|
|
vc_free_channel_list(&cl); // idempotent
|
|
|
|
vc_user_list ul{};
|
|
CHECK(vc_list_users(c, &ul) == VC_OK);
|
|
CHECK(ul.count == 0);
|
|
vc_free_user_list(&ul);
|
|
vc_free_user_list(&ul); // idempotent
|
|
|
|
// No users known yet — any user_id is "unknown".
|
|
vc_stream_summary_list sl{};
|
|
CHECK(vc_list_user_streams(c, 1, &sl) == VC_ERR_INVALID_ARG);
|
|
|
|
vc_client_destroy(c);
|
|
std::printf("test_pre_data_empty: ok\n");
|
|
}
|
|
|
|
// ── 2-6. Real connect/auth/join/stream against a real in-process server ────────
|
|
static void test_live_channel_user_list() {
|
|
auto tmp = std::filesystem::temp_directory_path() /
|
|
("vctest_chanlist_" + std::to_string(
|
|
std::chrono::steady_clock::now().time_since_epoch().count()));
|
|
std::filesystem::create_directories(tmp);
|
|
std::string data_dir = tmp.string();
|
|
|
|
std::atomic<uint16_t> bound_port{0};
|
|
std::mutex ready_mu;
|
|
std::condition_variable ready_cv;
|
|
bool ready{false};
|
|
|
|
voicecat::server::Config cfg;
|
|
cfg.data_dir = data_dir;
|
|
cfg.bind_port = 0;
|
|
cfg.media_port = 0;
|
|
cfg.server_name = "VoiceCat-ChanListTest";
|
|
cfg.allow_guests = true;
|
|
cfg.on_ready = [&](uint16_t p) {
|
|
bound_port.store(p);
|
|
{ std::lock_guard lk(ready_mu); ready = true; }
|
|
ready_cv.notify_all();
|
|
};
|
|
|
|
voicecat::server::Server server(cfg);
|
|
std::thread server_thread([&] { server.run(); });
|
|
|
|
{
|
|
std::unique_lock lk(ready_mu);
|
|
bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; });
|
|
if (!ok) {
|
|
std::printf("FAIL: server did not become ready within 10s\n");
|
|
++g_failures;
|
|
server.stop();
|
|
server_thread.join();
|
|
std::filesystem::remove_all(tmp);
|
|
return;
|
|
}
|
|
}
|
|
|
|
uint16_t port = bound_port.load();
|
|
std::printf("test_live_channel_user_list: server ready on :%u\n", port);
|
|
|
|
EventStore evA;
|
|
evA.label = "A";
|
|
vc_callbacks cbA{on_event, nullptr, &evA};
|
|
vc_config cfgA{"test-A", "0.1", VC_LOG_OFF};
|
|
vc_client* clientA = vc_client_create(&cfgA, cbA);
|
|
CHECK(clientA != nullptr);
|
|
evA.client = clientA;
|
|
CHECK(vc_connect(clientA, "127.0.0.1", port) == VC_OK);
|
|
CHECK(vc_authenticate_guest(clientA, "CL-A") == VC_OK);
|
|
CHECK(wait_for(evA, [](EventStore& s) { return s.auth_ok; }, 8000));
|
|
CHECK(wait_for(evA, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
|
|
|
CHECK(vc_join_voice(clientA) == VC_OK);
|
|
CHECK(wait_for(evA, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
|
|
|
EventStore evB;
|
|
evB.label = "B";
|
|
vc_callbacks cbB{on_event, nullptr, &evB};
|
|
vc_config cfgB{"test-B", "0.1", VC_LOG_OFF};
|
|
vc_client* clientB = vc_client_create(&cfgB, cbB);
|
|
CHECK(clientB != nullptr);
|
|
evB.client = clientB;
|
|
CHECK(vc_connect(clientB, "127.0.0.1", port) == VC_OK);
|
|
CHECK(vc_authenticate_guest(clientB, "CL-B") == VC_OK);
|
|
CHECK(wait_for(evB, [](EventStore& s) { return s.auth_ok; }, 8000));
|
|
CHECK(wait_for(evB, [](EventStore& s) { return s.channel_list_received; }, 3000));
|
|
|
|
CHECK(vc_join_voice(clientB) == VC_OK);
|
|
CHECK(wait_for(evB, [](EventStore& s) { return s.voice_subscribed; }, 5000));
|
|
|
|
uint32_t a_uid = 0, b_uid = 0;
|
|
{ std::lock_guard lk(evA.mu); a_uid = evA.self_user_id; }
|
|
{ std::lock_guard lk(evB.mu); b_uid = evB.self_user_id; }
|
|
|
|
// ── 2a. vc_list_channels reflects the real server config (regression test for the
|
|
// SessionModel field-population fix — parent_id/password_protected/max_users). ──────────
|
|
{
|
|
vc_channel_list cl{};
|
|
CHECK(vc_list_channels(clientA, &cl) == VC_OK);
|
|
CHECK(cl.count == 2); // Lobby (1) + Music Room (2), per session_registry.cpp
|
|
bool found_lobby = false;
|
|
for (size_t i = 0; i < cl.count; ++i) {
|
|
CHECK(cl.items[i].name != nullptr);
|
|
if (cl.items[i].id == 1) {
|
|
found_lobby = true;
|
|
CHECK(std::strcmp(cl.items[i].name, "Lobby") == 0);
|
|
CHECK(cl.items[i].parent_id == 0);
|
|
CHECK(cl.items[i].password_protected == 0);
|
|
CHECK(cl.items[i].max_users == 20); // non-default — proves the fix
|
|
}
|
|
}
|
|
CHECK(found_lobby);
|
|
vc_free_channel_list(&cl);
|
|
vc_free_channel_list(&cl); // idempotent
|
|
}
|
|
|
|
// ── 2b. vc_list_users includes both A and B with correct nickname/channel_id ─────────────
|
|
{
|
|
vc_user_list ul{};
|
|
CHECK(vc_list_users(clientB, &ul) == VC_OK);
|
|
CHECK(ul.count == 2);
|
|
bool found_a = false, found_b = false;
|
|
for (size_t i = 0; i < ul.count; ++i) {
|
|
CHECK(ul.items[i].nickname != nullptr);
|
|
CHECK(ul.items[i].channel_id == 1); // both default into Lobby on auth
|
|
if (ul.items[i].id == a_uid) { found_a = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-A") == 0); }
|
|
if (ul.items[i].id == b_uid) { found_b = true; CHECK(std::strcmp(ul.items[i].nickname, "CL-B") == 0); }
|
|
}
|
|
CHECK(found_a);
|
|
CHECK(found_b);
|
|
vc_free_user_list(&ul);
|
|
vc_free_user_list(&ul); // idempotent
|
|
}
|
|
|
|
// ── 3. vc_list_user_streams reflects a real stream, cross-checked against the
|
|
// STREAM_STARTED event's stream_id. ──────────────────────────────────────────────────────
|
|
vc_stream_desc mic_desc{};
|
|
mic_desc.kind = VC_STREAM_MIC;
|
|
mic_desc.label = "mic";
|
|
uint32_t mic_sid = 0;
|
|
CHECK(vc_stream_start(clientA, &mic_desc, &mic_sid) == VC_OK);
|
|
CHECK(wait_for(evB, [](EventStore& s) { return !s.stream_events.empty(); }, 5000));
|
|
|
|
{
|
|
vc_stream_summary_list sl{};
|
|
CHECK(vc_list_user_streams(clientB, a_uid, &sl) == VC_OK);
|
|
CHECK(sl.count == 1);
|
|
if (sl.count == 1) {
|
|
CHECK(sl.items[0].stream_id == mic_sid);
|
|
CHECK(sl.items[0].kind == VC_STREAM_MIC);
|
|
CHECK(sl.items[0].label != nullptr);
|
|
}
|
|
vc_free_stream_summary_list(&sl);
|
|
vc_free_stream_summary_list(&sl); // idempotent
|
|
}
|
|
|
|
// ── 4. Unknown user_id ───────────────────────────────────────────────────────────────────
|
|
{
|
|
vc_stream_summary_list sl{};
|
|
CHECK(vc_list_user_streams(clientB, 0xDEADBEEF, &sl) == VC_ERR_INVALID_ARG);
|
|
}
|
|
|
|
// ── 5. VC_EVENT_JOIN_RESULT — success (re-join the channel already in) and failure
|
|
// (unknown channel id). ─────────────────────────────────────────────────────────────────
|
|
CHECK(vc_join_channel(clientA, 1, nullptr) == VC_OK);
|
|
CHECK(wait_for(evA, [](EventStore& s) { return !s.join_results.empty(); }, 3000));
|
|
{
|
|
std::lock_guard lk(evA.mu);
|
|
CHECK(evA.join_results.back().ok);
|
|
CHECK(evA.join_results.back().channel_id == 1);
|
|
}
|
|
|
|
size_t mark;
|
|
{ std::lock_guard lk(evA.mu); mark = evA.join_results.size(); }
|
|
CHECK(vc_join_channel(clientA, 999999, nullptr) == VC_OK);
|
|
CHECK(wait_for(evA, [&](EventStore& s) { return s.join_results.size() > mark; }, 3000));
|
|
{
|
|
std::lock_guard lk(evA.mu);
|
|
CHECK(!evA.join_results.back().ok);
|
|
CHECK(!evA.join_results.back().error.empty());
|
|
}
|
|
|
|
vc_stream_stop(clientA, mic_sid);
|
|
vc_disconnect(clientA);
|
|
vc_disconnect(clientB);
|
|
vc_client_destroy(clientA);
|
|
vc_client_destroy(clientB);
|
|
|
|
server.stop();
|
|
server_thread.join();
|
|
std::filesystem::remove_all(tmp);
|
|
|
|
std::printf("test_live_channel_user_list: done\n");
|
|
}
|
|
|
|
int main() {
|
|
test_pre_data_empty();
|
|
test_live_channel_user_list();
|
|
|
|
if (g_failures == 0) {
|
|
std::printf("channel_user_list_abi: all checks passed\n");
|
|
return 0;
|
|
}
|
|
std::printf("channel_user_list_abi: %d failure(s)\n", g_failures);
|
|
return 1;
|
|
}
|
|
|
|
#else // !VOICECAT_HAS_NET
|
|
|
|
int main() {
|
|
std::printf("channel_user_list_abi: SKIP (VOICECAT_HAS_NET not defined)\n");
|
|
return 0;
|
|
}
|
|
|
|
#endif // VOICECAT_HAS_NET
|