M1 step 4a: IRC protocol core (host-tested)

lib/irc: IrcMessage parse/serialize, base64, ReconnectPolicy
(5 s .. 5 min), IrcConfig (validated, persisted) and IrcSession:
registration with SASL PLAIN or NickServ, nick fallback, PING, Buffers
capped at 50 lines with unread counts, Mentions, CTCP ACTION/VERSION,
topics, /names on request only, others' join/part/quit hidden, user
commands, and rejoining after a pause. No networking: the session
returns lines to send, Log entries and Notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-02 15:03:21 +02:00
co-authored by Claude Opus 5.5
parent d3948ccc3a
commit 311eb38723
11 changed files with 1142 additions and 0 deletions
@@ -0,0 +1,74 @@
#include <unity.h>
#include "base64.h"
#include "irc_message.h"
#include "reconnect_policy.h"
using namespace roro;
void setUp() {}
void tearDown() {}
void test_parse_full_message() {
auto m = IrcMessage::parse(":alice!a@host PRIVMSG #roro :hello there");
TEST_ASSERT_EQUAL_STRING("alice!a@host", m.prefix.c_str());
TEST_ASSERT_EQUAL_STRING("alice", m.nick().c_str());
TEST_ASSERT_EQUAL_STRING("PRIVMSG", m.command.c_str());
TEST_ASSERT_EQUAL(2, m.params.size());
TEST_ASSERT_EQUAL_STRING("#roro", m.params[0].c_str());
TEST_ASSERT_EQUAL_STRING("hello there", m.params[1].c_str());
}
void test_parse_without_prefix_and_with_tags() {
auto m = IrcMessage::parse("@time=2026-10-02T12:00:00Z PING :abc");
TEST_ASSERT_EQUAL_STRING("", m.prefix.c_str());
TEST_ASSERT_EQUAL_STRING("PING", m.command.c_str());
TEST_ASSERT_EQUAL_STRING("abc", m.params[0].c_str());
}
void test_parse_strips_line_endings_and_handles_empty_trailing() {
auto m = IrcMessage::parse(":srv 332 me #roro :\r\n");
TEST_ASSERT_EQUAL(3, m.params.size());
TEST_ASSERT_EQUAL_STRING("", m.params[2].c_str());
}
void test_parse_middle_params_only() {
auto m = IrcMessage::parse(":srv MODE #roro +nt");
TEST_ASSERT_EQUAL(2, m.params.size());
TEST_ASSERT_EQUAL_STRING("+nt", m.params[1].c_str());
}
void test_serialize_adds_colon_only_when_needed() {
TEST_ASSERT_EQUAL_STRING("NICK roro", IrcMessage::serialize("NICK", {"roro"}).c_str());
TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro :hello there", IrcMessage::serialize("PRIVMSG", {"#roro", "hello there"}).c_str());
TEST_ASSERT_EQUAL_STRING("PRIVMSG #roro ::)", IrcMessage::serialize("PRIVMSG", {"#roro", ":)"}).c_str());
TEST_ASSERT_EQUAL_STRING("TOPIC #roro :", IrcMessage::serialize("TOPIC", {"#roro", ""}).c_str());
}
void test_base64() {
TEST_ASSERT_EQUAL_STRING("", base64Encode("").c_str());
TEST_ASSERT_EQUAL_STRING("Zg==", base64Encode("f").c_str());
TEST_ASSERT_EQUAL_STRING("Zm8=", base64Encode("fo").c_str());
TEST_ASSERT_EQUAL_STRING("Zm9v", base64Encode("foo").c_str());
TEST_ASSERT_EQUAL_STRING("dXNlcgB1c2VyAHBhc3M=", base64Encode(std::string("user\0user\0pass", 14)).c_str());
}
void test_reconnect_delays_grow_then_cap_and_reset() {
ReconnectPolicy p;
uint32_t expected[] = {5000, 10000, 30000, 60000, 120000, 300000, 300000};
for (uint32_t e : expected) TEST_ASSERT_EQUAL_UINT32(e, p.nextDelayMs());
p.reset();
TEST_ASSERT_EQUAL_UINT32(5000, p.nextDelayMs());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_parse_full_message);
RUN_TEST(test_parse_without_prefix_and_with_tags);
RUN_TEST(test_parse_strips_line_endings_and_handles_empty_trailing);
RUN_TEST(test_parse_middle_params_only);
RUN_TEST(test_serialize_adds_colon_only_when_needed);
RUN_TEST(test_base64);
RUN_TEST(test_reconnect_delays_grow_then_cap_and_reset);
return UNITY_END();
}