Public Access
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
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
#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();
|
||||
}
|
||||
Reference in New Issue
Block a user