diff --git a/feed.odin b/feed.odin index fb4c0a5..7e2acaa 100644 --- a/feed.odin +++ b/feed.odin @@ -111,8 +111,7 @@ generate_sitemap :: proc(site: ^Site) -> string { return strings.to_string(sb) } -// TODO: Leaks -format_rfc822 :: proc(iso: string) -> string { +format_rfc822 :: proc(iso: string, allocator := context.temp_allocator) -> string { if len(iso) < 19 { // TODO: should indicate error somehow return iso @@ -134,6 +133,7 @@ format_rfc822 :: proc(iso: string) -> string { t, offset / 60, offset % 60, + allocator = allocator, ) } diff --git a/feed_test.odin b/feed_test.odin new file mode 100644 index 0000000..54abbfc --- /dev/null +++ b/feed_test.odin @@ -0,0 +1,153 @@ +#+test +package main + +import "core:strings" +import "core:testing" + +// --------------------------------------------------------------------------- +// xml_escape +// --------------------------------------------------------------------------- + +@(test) +test_xml_escape_passthrough :: proc(t: ^testing.T) { + testing.expect_value(t, xml_escape(""), "") + testing.expect_value(t, xml_escape("hello world"), "hello world") + // UTF-8 multibyte bytes are not metacharacters and pass through untouched. + testing.expect_value(t, xml_escape("héllo — 世界"), "héllo — 世界") +} + +@(test) +test_xml_escape_entities :: proc(t: ^testing.T) { + testing.expect_value(t, xml_escape("&"), "&") + testing.expect_value(t, xml_escape("<"), "<") + testing.expect_value(t, xml_escape(">"), ">") + // Adjacent specials, and specials at the very start/end of the string. + testing.expect_value(t, xml_escape("&<>"), "&<>") + testing.expect_value(t, xml_escape(""), "<a>") +} + +@(test) +test_xml_escape_mixed :: proc(t: ^testing.T) { + testing.expect_value(t, xml_escape("ac&d"), "a<b>c&d") + testing.expect_value( + t, + xml_escape("

Tom & Jerry

"), + "<p>Tom & Jerry</p>", + ) +} + +// --------------------------------------------------------------------------- +// format_rfc822 +// --------------------------------------------------------------------------- + +@(test) +test_format_rfc822_short_passthrough :: proc(t: ^testing.T) { + // Inputs shorter than a full ISO-8601 timestamp are returned unchanged + // (and are the input slice itself, so they must not be deleted). + testing.expect_value(t, format_rfc822("2024"), "2024") + testing.expect_value(t, format_rfc822(""), "") +} + +@(test) +test_format_rfc822_full_date :: proc(t: ^testing.T) { + got := format_rfc822("2024-01-15T10:30:00Z") + // Result is temp-allocated (reset by the test runner), so no delete. + // 2024-01-15 is a Monday. Assert the stable leading portion; the trailing + // offset rendering is intentionally left unpinned here. + testing.expect(t, strings.has_prefix(got, "Mon, 15 Jan 2024 10:30:00"), got) +} + +// --------------------------------------------------------------------------- +// generate_rss +// --------------------------------------------------------------------------- + +@(test) +test_generate_rss_structure :: proc(t: ^testing.T) { + site: Site + site.title = "My & Site" + site.description = "Desc " + site.base_url = "https://example.com" + site.pages = make(#soa[dynamic]Page) + defer delete_soa(site.pages) + + // A real content page. + append( + &site.pages, + Page { + section = "posts", + title = "First ", + url = "https://example.com/posts/first/", + content = "

Hello & welcome

", + }, + ) + // The home index page (section == "" && _is_index) must be skipped. + append( + &site.pages, + Page { + section = "", + _is_index = true, + title = "HOME_SHOULD_BE_SKIPPED", + url = "https://example.com/", + content = "home", + }, + ) + // A second content page. + append( + &site.pages, + Page { + section = "posts", + title = "Second Post", + url = "https://example.com/posts/second/", + content = "plain body", + }, + ) + + rss := generate_rss(&site) + defer delete(rss) + + // Channel header: site title/description are XML-escaped, link gets a + // trailing slash, and the atom self-link points at index.xml. + testing.expect(t, strings.contains(rss, "My & Site"), rss) + testing.expect( + t, + strings.contains(rss, "Desc <here>"), + rss, + ) + testing.expect(t, strings.contains(rss, "https://example.com/"), rss) + testing.expect( + t, + strings.contains(rss, `s — the home index page is skipped. + testing.expect_value(t, strings.count(rss, ""), 2) + testing.expect(t, !strings.contains(rss, "HOME_SHOULD_BE_SKIPPED"), rss) + + // First item: title and body are XML-escaped, link is verbatim. + testing.expect(t, strings.contains(rss, "First <Post>"), rss) + testing.expect( + t, + strings.contains( + rss, + "<p>Hello & welcome</p>", + ), + rss, + ) + testing.expect( + t, + strings.contains(rss, "https://example.com/posts/first/"), + rss, + ) + + // Undated pages get the sentinel pubDate (no date formatting/allocation). + testing.expect( + t, + strings.contains(rss, "Mon, 01 Jan 0001 00:00:00 +0000"), + rss, + ) + + // Well-formed at the boundaries. + testing.expect(t, strings.has_prefix(rss, `\n"), rss) +}