2786f7b1bb
The poll signature grew to accept lang/tags/mood/visibility a while back so the Compose view could pass them through to NewPostOptions.poll, but nothing pinned that behaviour. Two tests: - With all four provided, the generated block contains :LANG:, :TAGS:, :MOOD:, :VISIBILITY: alongside the existing :POLL_END:. - With none provided, those properties are absent (so the default call site keeps producing minimal poll blocks).
238 lines
9.6 KiB
Swift
238 lines
9.6 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)")
|
||
}
|
||
}
|
||
}
|