27a9bbffac
- 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
363 lines
13 KiB
Swift
363 lines
13 KiB
Swift
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."))
|
||
}
|
||
}
|
||
|