Files
andros 27a9bbffac Render code blocks in posts; fix parser for nested :PROPERTIES: in fenced blocks; bump 1.3 (19)
- Add HighlighterSwift dependency for syntax-highlighted #+BEGIN_SRC blocks
- New CodeBlockView: SRC (highlighted), QUOTE (accent border), EXAMPLE (monospace)
- Fix OrgSocialParser.parsePostBlock: break after outer :END: so nested :PROPERTIES:/:END: inside #+BEGIN_SRC blocks no longer overwrite contentStart and eat intro text
- Fix OrgSocialParser.extractText: preserve fenced block delimiters and content
- Fix OrgSocialParser post-grouping: ignore ** headings inside fenced blocks
- Fix PartialFeedFetcher.extractRecentPostBlocks: same fenced-block guard
- Add 9 OrgBodyRenderer edge-case tests (block at start/end, multiple blocks, org headings inside block, etc.)
- Add 2 parser tests for nested :PROPERTIES: inside fenced block body
2026-05-22 08:12:30 +02:00

363 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import OrgSocialKit
private let feed = """
#+TITLE: Test
#+NICK: test
* Posts
"""
private let feedURL = URL(string: "https://example.com/social.org")!
private let relay = URL(string: "https://relay.org-social.org")!
// Poll post with live votes on relay (andros Nov 2025)
private let livePollURL = "https://host.org-social.org/andros/social.org#2025-11-26T09:03:48+0100"
@Suite("PostWriter boost block")
struct BoostBlockTests {
let writer = PostWriter()
@Test("boost factory creates INCLUDE property")
func boostFactory() {
let opts = NewPostOptions.boost(of: "https://shom.dev/social.org#2025-09-06T22:36:20-0500")
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains(":INCLUDE: https://shom.dev/social.org#2025-09-06T22:36:20-0500"))
}
@Test("boost with commentary includes body text")
func boostWithCommentary() {
let opts = NewPostOptions.boost(of: "https://shom.dev/social.org#2025-09-06T22:36:20-0500", commentary: "Great post!")
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains("Great post!"))
#expect(block.contains(":INCLUDE:"))
}
@Test("pure boost (no commentary) has no body text")
func pureBoostNoBody() throws {
let opts = NewPostOptions.boost(of: "https://shom.dev/social.org#2025-09-06T22:36:20-0500")
let (updated, _) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts)
// The block must not contain any non-empty lines after :END:
let lines = updated.components(separatedBy: "\n")
let endIdx = lines.lastIndex { $0.trimmingCharacters(in: .whitespaces) == ":END:" } ?? 0
let bodyLines = lines[(endIdx + 1)...].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
#expect(bodyLines.isEmpty)
}
}
@Suite("PostWriter poll block")
struct PollBlockTests {
let writer = PostWriter()
@Test("poll factory creates POLL_END property")
func pollHasPollEnd() {
let end = Date(timeIntervalSinceNow: 7 * 86400)
let opts = NewPostOptions.poll(question: "Which editor?", options: ["Emacs", "Vim"], end: end)
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains(":POLL_END:"))
}
@Test("poll question and options are separated by a blank line")
func pollBlankLineBetweenQuestionAndOptions() {
let end = Date(timeIntervalSinceNow: 86400)
let opts = NewPostOptions.poll(question: "Pick one", options: ["A"], end: end)
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains("Pick one\n\n- [ ] A"))
}
@Test("poll body contains checkboxes for each option")
func pollCheckboxes() {
let end = Date(timeIntervalSinceNow: 86400)
let opts = NewPostOptions.poll(question: "Pick one", options: ["A", "B", "C"], end: end)
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains("- [ ] A"))
#expect(block.contains("- [ ] B"))
#expect(block.contains("- [ ] C"))
}
@Test("poll vote creates POLL_OPTION property")
func pollVoteBlock() {
let opts = NewPostOptions.vote(for: "Emacs", on: "https://shom.dev/social.org#2025-01-01T00:00:00Z")
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains(":POLL_OPTION: Emacs"))
#expect(block.contains(":REPLY_TO: https://shom.dev/social.org#2025-01-01T00:00:00Z"))
}
@Test("vote block has no body text")
func voteNoBody() throws {
let opts = NewPostOptions.vote(for: "Emacs", on: "https://example.com/social.org#2025-01-01T00:00:00Z")
let (updated, _) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts)
let lines = updated.components(separatedBy: "\n")
let endIdx = lines.lastIndex { $0.trimmingCharacters(in: .whitespaces) == ":END:" } ?? 0
let bodyLines = lines[(endIdx + 1)...].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
#expect(bodyLines.isEmpty)
}
@Test("poll factory emits LANG, TAGS, MOOD, VISIBILITY when provided")
func pollAcceptsSharedFields() {
let end = Date(timeIntervalSinceNow: 86400)
let opts = NewPostOptions.poll(
question: "Pick one",
options: ["A", "B"],
end: end,
lang: "es",
tags: "poll survey",
mood: "🗳",
visibility: .mention
)
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains(":LANG: es"))
#expect(block.contains(":TAGS: poll survey"))
#expect(block.contains(":MOOD: 🗳"))
#expect(block.contains(":VISIBILITY: mention"))
#expect(block.contains(":POLL_END:"))
}
@Test("poll factory omits LANG/TAGS/MOOD/VISIBILITY when not provided")
func pollOmitsAbsentSharedFields() {
let end = Date(timeIntervalSinceNow: 86400)
let opts = NewPostOptions.poll(question: "Pick one", options: ["A"], end: end)
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(!block.contains(":LANG:"))
#expect(!block.contains(":TAGS:"))
#expect(!block.contains(":MOOD:"))
#expect(!block.contains(":VISIBILITY:"))
}
}
@Suite("PostWriter migration block")
struct MigrationBlockTests {
let writer = PostWriter()
@Test("migration factory creates MIGRATION property")
func migrationBlock() {
let opts = NewPostOptions.migration(from: "https://old.example.com/social.org",
to: "https://new.example.com/social.org")
let block = writer.buildPostBlock(timestamp: "2025-01-01T12:00:00+01:00", options: opts)
#expect(block.contains(":MIGRATION: https://old.example.com/social.org https://new.example.com/social.org"))
}
}
@Suite("PostWriter scheduled post")
struct ScheduledPostTests {
let writer = PostWriter()
@Test("appendPost with future date uses that timestamp")
func futureTimestamp() throws {
let future = Date(timeIntervalSinceNow: 3600)
let opts = NewPostOptions(text: "Scheduled post")
let (_, postURL) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts, date: future)
let timestamp = postURL.components(separatedBy: "#").last ?? ""
// The timestamp must parse to a future date (parse helper accepts
// both compact and colon forms, matching the spec).
let parsed = PostWriter.parseTimestamp(timestamp)
#expect(parsed != nil)
#expect(parsed! > Date())
}
@Test("generateTimestamp emits compact timezone form (no colon)")
func timestampCompactForm() {
let ts = writer.generateTimestamp(for: Date(timeIntervalSince1970: 0))
// Last 5 chars must be `±HHMM` (compact) or exactly match `Z` is
// disallowed by the new emission contract we always emit `±HHMM`.
let suffix = String(ts.suffix(5))
let matches = suffix.range(of: #"^[+-]\d{4}$"#, options: .regularExpression) != nil
#expect(matches, "expected compact timezone, got: \(ts)")
}
@Test("scheduled post block contains future timestamp in heading")
func futureTimestampInHeading() throws {
let future = Date(timeIntervalSinceNow: 3600)
let opts = NewPostOptions(text: "Will post later")
let (updated, _) = try writer.appendPost(to: feed, feedURL: feedURL, options: opts, date: future)
let generatedTimestamp = writer.generateTimestamp(for: future)
#expect(updated.contains("** \(generatedTimestamp)"))
}
}
@Suite("RelayClient poll votes", .serialized)
struct PollVotesTests {
let client = RelayClient()
@Test("fetchPollVotes returns options for known poll")
func fetchVotesNonEmpty() async throws {
let votes = try await client.fetchPollVotes(for: livePollURL, from: relay)
#expect(!votes.isEmpty)
}
@Test("fetchPollVotes options have non-empty names")
func voteOptionNames() async throws {
let votes = try await client.fetchPollVotes(for: livePollURL, from: relay)
for vote in votes {
#expect(!vote.option.isEmpty)
}
}
@Test("fetchPollVotes total vote count matches individual counts")
func voteTotals() async throws {
let votes = try await client.fetchPollVotes(for: livePollURL, from: relay)
let total = votes.reduce(0) { $0 + $1.voteCount }
#expect(total >= 0)
}
}
@Suite("RelayClient groups", .serialized)
struct GroupsTests {
let client = RelayClient()
@Test("fetchGroupList returns known groups")
func groupListNonEmpty() async throws {
let groups = try await client.fetchGroupList(from: relay)
#expect(!groups.isEmpty)
}
@Test("group names and slugs are non-empty strings")
func groupNamesValid() async throws {
let groups = try await client.fetchGroupList(from: relay)
for g in groups {
#expect(!g.name.isEmpty)
#expect(!g.slug.isEmpty)
}
}
@Test("fetchGroupPostURLs returns posts for Emacs group")
func emacsGroupPosts() async throws {
let urls = try await client.fetchGroupPostURLs(slug: "emacs", from: relay)
#expect(!urls.isEmpty)
for url in urls {
#expect(url.contains("#"), "Group post URL missing #: \(url)")
}
}
}
@Suite("Parser BOT property")
struct BotPropertyTests {
private let parser = OrgSocialParser()
private func parse(_ extra: String, body: String = "") -> OrgSocialPost? {
let src = """
#+TITLE: Test
#+NICK: bot-feed
* Posts
** 2025-05-01T12:00:00+0100
:PROPERTIES:
\(extra)
:END:
\(body)
"""
return parser.parse(src).posts.first
}
@Test("BOT property is stored on the parsed post")
func botIsParsed() {
let post = parse(":BOT: chess 1.e4 e5")
#expect(post?.bot == "chess 1.e4 e5")
}
@Test("BOT with no params stores just the type")
func botTypeOnly() {
let post = parse(":BOT: weather")
#expect(post?.bot == "weather")
}
@Test("BOT value is not included in the post body text")
func botNotInBody() {
let post = parse(":BOT: chess 1.e4 e5", body: "White opens e4.")
#expect(post?.text.contains("BOT") == false)
#expect(post?.text.contains("chess") == false)
#expect(post?.text.trimmingCharacters(in: .whitespacesAndNewlines) == "White opens e4.")
}
@Test("posts without BOT have nil bot field")
func noBotIsNil() {
let post = parse(":CLIENT: test-client")
#expect(post?.bot == nil)
}
}
@Suite("Parser nested :PROPERTIES: in fenced block body")
struct NestedPropertiesInFencedBlockTests {
private let parser = OrgSocialParser()
@Test("intro text is preserved when body contains #+BEGIN_SRC with nested :PROPERTIES: blocks")
func introTextPreservedWithNestedProps() {
let src = """
#+TITLE: Test
#+NICK: testuser
* Posts
** 2026-05-21T10:22:42+0200
:PROPERTIES:
:LANG: en
:CLIENT: org-social.el
:END:
Intro paragraph one.
More intro text.
#+BEGIN_SRC org
** 2025-05-21T10:00:00+0100
:PROPERTIES:
:BOT: chess match Alice
:CLIENT: org-social.el
:END:
Chess match request.
** 2025-05-21T10:06:00+0100
:PROPERTIES:
:BOT: chess e2e4
:CLIENT: org-social.el
:END:
a b c d e f g h
8 ♜ ♞ ♝ ♛ ♚ ♝ ♞ ♜
#+END_SRC
"""
let posts = parser.parse(src).posts
#expect(posts.count == 1)
let post = posts[0]
#expect(post.text.contains("Intro paragraph one."))
#expect(post.text.contains("More intro text."))
#expect(post.text.contains("#+BEGIN_SRC org"))
#expect(post.text.contains("#+END_SRC"))
#expect(post.lang == "en")
#expect(post.client == "org-social.el")
}
@Test("contentStart is not bumped by nested :END: outside a fenced block")
func contentStartNotBumpedByNestedEnd() {
let src = """
#+TITLE: Test
#+NICK: testuser
* Posts
** 2026-01-01T09:00:00+0100
:PROPERTIES:
:CLIENT: test
:END:
First line of body.
Second line of body.
"""
let posts = parser.parse(src).posts
#expect(posts.count == 1)
#expect(posts[0].text.contains("First line of body."))
#expect(posts[0].text.contains("Second line of body."))
}
}