Files
roro9stack/test/test_gemini/test_gemini.cpp
T
twislaandClaude Opus 5.5 babb1d114e G1 step 2: Gemini parsers (host-tested), and the memory decision (Q86)
lib/gemini: URLs split per RFC 3986 appendix B and resolved per section
5.2 (all 32 reference examples of 5.4 pass, with gemini:// for http://),
the request form (no fragment, lower-case host, never an empty path),
query encoding for input prompts, Saved Page paths; the response header
(status, category, MIME type and parameters, 1024-byte meta limit);
gemtext's line types; and display text for the Latin-1 fonts. 12 tests.

Q86, decided after step 1: free heap stays above 40 KB in steady state
and 20 KB during a handshake; a fetch won't start below 55 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 00:29:55 +02:00

193 lines
8.8 KiB
C++

#include <unity.h>
#include <string>
#include "gemini_response.h"
#include "gemini_url.h"
#include "gemtext.h"
using namespace roro::gemini;
void setUp() {}
void tearDown() {}
static void resolvesTo(const char* ref, const char* expected) {
TEST_ASSERT_EQUAL_STRING_MESSAGE(expected, resolve("gemini://a/b/c/d;p?q", ref).c_str(), ref);
}
// RFC 3986, section 5.4.1, with gemini:// for http://.
void test_rfc3986_normal_examples() {
resolvesTo("g:h", "g:h");
resolvesTo("g", "gemini://a/b/c/g");
resolvesTo("./g", "gemini://a/b/c/g");
resolvesTo("g/", "gemini://a/b/c/g/");
resolvesTo("/g", "gemini://a/g");
resolvesTo("//g", "gemini://g");
resolvesTo("?y", "gemini://a/b/c/d;p?y");
resolvesTo("g?y", "gemini://a/b/c/g?y");
resolvesTo("#s", "gemini://a/b/c/d;p?q#s");
resolvesTo("g#s", "gemini://a/b/c/g#s");
resolvesTo("g?y#s", "gemini://a/b/c/g?y#s");
resolvesTo(";x", "gemini://a/b/c/;x");
resolvesTo("", "gemini://a/b/c/d;p?q");
resolvesTo(".", "gemini://a/b/c/");
resolvesTo("./", "gemini://a/b/c/");
resolvesTo("..", "gemini://a/b/");
resolvesTo("../", "gemini://a/b/");
resolvesTo("../g", "gemini://a/b/g");
resolvesTo("../..", "gemini://a/");
resolvesTo("../../", "gemini://a/");
resolvesTo("../../g", "gemini://a/g");
}
// Section 5.4.2.
void test_rfc3986_abnormal_examples() {
resolvesTo("../../../g", "gemini://a/g");
resolvesTo("../../../../g", "gemini://a/g");
resolvesTo("/./g", "gemini://a/g");
resolvesTo("/../g", "gemini://a/g");
resolvesTo("g.", "gemini://a/b/c/g.");
resolvesTo(".g", "gemini://a/b/c/.g");
resolvesTo("./../g", "gemini://a/b/g");
resolvesTo("g/./h", "gemini://a/b/c/g/h");
resolvesTo("g/../h", "gemini://a/b/c/h");
resolvesTo("g;x=1/./y", "gemini://a/b/c/g;x=1/y");
resolvesTo("g;x=1/../y", "gemini://a/b/c/y");
}
void test_parse_a_gemini_url() {
Url u;
TEST_ASSERT_TRUE(parseUrl("gemini://Example.ORG:1966/a/b?c=d#e", u));
TEST_ASSERT_EQUAL_STRING("gemini", u.scheme.c_str());
TEST_ASSERT_EQUAL_STRING("example.org", u.host.c_str()); // hosts are case-insensitive
TEST_ASSERT_EQUAL(1966, u.port);
TEST_ASSERT_EQUAL_STRING("/a/b", u.path.c_str());
TEST_ASSERT_EQUAL_STRING("c=d", u.query.c_str());
TEST_ASSERT_EQUAL_STRING("e", u.fragment.c_str());
TEST_ASSERT_TRUE(parseUrl("gemini://example.org", u));
TEST_ASSERT_EQUAL(1965, u.portOrDefault());
TEST_ASSERT_FALSE(parseUrl("not a url", u));
}
void test_the_request_drops_the_fragment_and_never_has_an_empty_path() {
TEST_ASSERT_EQUAL_STRING("gemini://example.org/", requestUrl("gemini://example.org").c_str());
TEST_ASSERT_EQUAL_STRING("gemini://example.org/a?b", requestUrl("gemini://example.org/a?b#frag").c_str());
TEST_ASSERT_EQUAL_STRING("gemini://example.org:1966/", requestUrl("gemini://EXAMPLE.org:1966").c_str());
}
void test_other_schemes_are_recognised() {
TEST_ASSERT_TRUE(isGemini("gemini://a/"));
TEST_ASSERT_FALSE(isGemini("https://a/"));
TEST_ASSERT_FALSE(isGemini("gopher://a/"));
TEST_ASSERT_FALSE(isGemini("mailto:someone@example.org"));
}
void test_query_encoding() {
TEST_ASSERT_EQUAL_STRING("hello%20world", encodeQuery("hello world").c_str());
TEST_ASSERT_EQUAL_STRING("caf%C3%A9%3F%26", encodeQuery("caf\xC3\xA9?&").c_str());
TEST_ASSERT_EQUAL_STRING("a-b_c.d~e", encodeQuery("a-b_c.d~e").c_str());
}
void test_response_header() {
Header h;
TEST_ASSERT_TRUE(parseHeader("20 text/gemini; charset=utf-8; lang=en", h));
TEST_ASSERT_EQUAL(20, h.status);
TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str());
TEST_ASSERT_EQUAL_STRING("utf-8", h.parameter("charset").c_str());
TEST_ASSERT_TRUE(h.category() == Category::Success);
TEST_ASSERT_TRUE(parseHeader("20", h)); // no MIME type: text/gemini by default
TEST_ASSERT_EQUAL_STRING("text/gemini", h.mimeType().c_str());
TEST_ASSERT_TRUE(parseHeader("31 gemini://cosmos.skyjake.fi/", h));
TEST_ASSERT_TRUE(h.category() == Category::Redirect);
TEST_ASSERT_EQUAL_STRING("gemini://cosmos.skyjake.fi/", h.meta.c_str());
TEST_ASSERT_TRUE(parseHeader("11 Password", h));
TEST_ASSERT_TRUE(h.category() == Category::Input);
TEST_ASSERT_TRUE(h.sensitiveInput());
TEST_ASSERT_TRUE(parseHeader("51 Not found", h));
TEST_ASSERT_TRUE(h.category() == Category::PermanentFailure);
TEST_ASSERT_TRUE(parseHeader("60 Certificate required", h));
TEST_ASSERT_TRUE(h.category() == Category::ClientCertificate);
}
void test_malformed_headers_are_refused() {
Header h;
TEST_ASSERT_FALSE(parseHeader("", h));
TEST_ASSERT_FALSE(parseHeader("2 text/gemini", h));
TEST_ASSERT_FALSE(parseHeader("ab text/gemini", h));
TEST_ASSERT_FALSE(parseHeader("20text/gemini", h));
TEST_ASSERT_FALSE(parseHeader("20 " + std::string(1025, 'x'), h));
}
void test_gemtext_line_types() {
auto lines = parseGemtext(
"# Title\n## Sub\n### Subsub\nPlain text\n* item\n> quoted\n=> gemini://a/b Label here\n=>/rel\n"
"```ascii art\n# not a heading\n=> not a link\n```\nafter\n");
TEST_ASSERT_EQUAL(13, static_cast<int>(lines.size()));
TEST_ASSERT_TRUE(lines[0].type == LineType::Heading1);
TEST_ASSERT_EQUAL_STRING("Title", lines[0].text.c_str());
TEST_ASSERT_TRUE(lines[1].type == LineType::Heading2);
TEST_ASSERT_TRUE(lines[2].type == LineType::Heading3);
TEST_ASSERT_TRUE(lines[3].type == LineType::Text);
TEST_ASSERT_TRUE(lines[4].type == LineType::ListItem);
TEST_ASSERT_EQUAL_STRING("item", lines[4].text.c_str());
TEST_ASSERT_TRUE(lines[5].type == LineType::Quote);
TEST_ASSERT_EQUAL_STRING("quoted", lines[5].text.c_str());
TEST_ASSERT_TRUE(lines[6].type == LineType::Link);
TEST_ASSERT_EQUAL_STRING("gemini://a/b", lines[6].url.c_str());
TEST_ASSERT_EQUAL_STRING("Label here", lines[6].text.c_str());
TEST_ASSERT_TRUE(lines[7].type == LineType::Link);
TEST_ASSERT_EQUAL_STRING("/rel", lines[7].url.c_str());
TEST_ASSERT_EQUAL_STRING("/rel", lines[7].text.c_str()); // no label: the URL is the label
TEST_ASSERT_TRUE(lines[8].type == LineType::PreToggle);
TEST_ASSERT_EQUAL_STRING("ascii art", lines[8].text.c_str());
TEST_ASSERT_TRUE(lines[9].type == LineType::Preformatted);
TEST_ASSERT_EQUAL_STRING("# not a heading", lines[9].text.c_str());
TEST_ASSERT_TRUE(lines[10].type == LineType::Preformatted);
TEST_ASSERT_TRUE(lines[11].type == LineType::PreToggle);
TEST_ASSERT_TRUE(lines[12].type == LineType::Text);
}
void test_gemtext_handles_crlf_and_a_missing_last_newline() {
auto lines = parseGemtext("one\r\n=>\tgemini://x/\t spaced label \r\ntwo");
TEST_ASSERT_EQUAL(3, static_cast<int>(lines.size()));
TEST_ASSERT_EQUAL_STRING("one", lines[0].text.c_str());
TEST_ASSERT_EQUAL_STRING("gemini://x/", lines[1].url.c_str());
TEST_ASSERT_EQUAL_STRING("spaced label", lines[1].text.c_str());
TEST_ASSERT_EQUAL_STRING("two", lines[2].text.c_str());
}
void test_display_text_keeps_latin1_and_replaces_the_rest() {
TEST_ASSERT_EQUAL_STRING("caf\xC3\xA9", displayText("caf\xC3\xA9").c_str()); // é stays
TEST_ASSERT_EQUAL_STRING("? and ?", displayText("\xE2\x9C\x93 and \xF0\x9F\x99\x82").c_str()); // ✓ and 🙂
TEST_ASSERT_EQUAL_STRING("bad ? byte", displayText("bad \xFF byte").c_str());
TEST_ASSERT_EQUAL_STRING("a b", displayText("a\tb").c_str()); // tab: three spaces here
}
void test_saved_page_paths() {
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org/").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/index.gmi", savedPath("gemini://example.org").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/2026/post.gmi",
savedPath("gemini://example.org/log/2026/post.gmi").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/log/index.gmi", savedPath("gemini://example.org/log/").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org/about.gmi", savedPath("gemini://example.org/about").c_str());
TEST_ASSERT_EQUAL_STRING("/gemini/saved/example.org_1966/a~q_x.gmi",
savedPath("gemini://example.org:1966/a?q:x#frag").c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_rfc3986_normal_examples);
RUN_TEST(test_rfc3986_abnormal_examples);
RUN_TEST(test_parse_a_gemini_url);
RUN_TEST(test_the_request_drops_the_fragment_and_never_has_an_empty_path);
RUN_TEST(test_other_schemes_are_recognised);
RUN_TEST(test_query_encoding);
RUN_TEST(test_response_header);
RUN_TEST(test_malformed_headers_are_refused);
RUN_TEST(test_gemtext_line_types);
RUN_TEST(test_gemtext_handles_crlf_and_a_missing_last_newline);
RUN_TEST(test_display_text_keeps_latin1_and_replaces_the_rest);
RUN_TEST(test_saved_page_paths);
return UNITY_END();
}