aa8b5563e6
PostRowView adds three alerts for the fire-and-forget interaction view models (boost / reaction / poll-vote) the same way the actions view model already had one. Their errorMessage was previously written into a property that no UI ever read — so a failed upload left the button in the wrong state with no signal to the user. ComposeViewModel gains draftKey + saveDraftIfTopLevel + clearDraft, and ComposeView wires onChange(of:text) / onDisappear to persist, plus clearDraft on successful publish. Top-level posts only; reply and group contexts are intentionally not drafted because their context URLs might be stale by the time the user reopens the sheet. ProfileView gets .refreshable that bypasses the CDN cache and also forces the follow coordinator to refresh — matches the existing Timeline pull-to-refresh so both tabs feel the same. Two new integration tests on PostWriter.editPost cover the full-fidelity edit path end-to-end: setting LANG/TAGS/MOOD/VISIBILITY + retargeting the timestamp, and the inverse — clearing every property. Three mention-renderer tests pin the public contract around inline `@nick` output and its `.link` attribute so refactors can't quietly break autocomplete round-tripping.
241 lines
8.4 KiB
Swift
241 lines
8.4 KiB
Swift
import Foundation
|
||
import Testing
|
||
@testable import OrgSocialKit
|
||
|
||
// Feed with 3 posts: heading-ID, property-ID, and a reaction post (property-ID with no body)
|
||
private let feed = """
|
||
#+TITLE: Mutation Test
|
||
#+NICK: tester
|
||
|
||
* Posts
|
||
|
||
** 2025-01-10T09:00:00+01:00
|
||
:PROPERTIES:
|
||
:LANG: en
|
||
:TAGS: swift
|
||
:CLIENT: org-social-ios
|
||
:END:
|
||
|
||
First post body.
|
||
|
||
** 2025-01-11T12:00:00+01:00
|
||
:PROPERTIES:
|
||
:LANG: en
|
||
:END:
|
||
|
||
Second post body.
|
||
|
||
**
|
||
:PROPERTIES:
|
||
:ID: 2025-01-12T08:00:00+01:00
|
||
:MOOD: ❤️
|
||
:REPLY_TO: https://shom.dev/social.org#2025-01-11T10:00:00+01:00
|
||
:END:
|
||
"""
|
||
|
||
@Suite("PostWriter – deletePost")
|
||
struct PostWriterDeleteTests {
|
||
|
||
let writer = PostWriter()
|
||
|
||
@Test("deletes a post identified by heading timestamp")
|
||
func deleteByHeadingTimestamp() throws {
|
||
let result = try writer.deletePost(timestamp: "2025-01-10T09:00:00+01:00", from: feed)
|
||
#expect(!result.contains("2025-01-10T09:00:00+01:00"))
|
||
#expect(!result.contains("First post body."))
|
||
#expect(result.contains("Second post body."))
|
||
}
|
||
|
||
@Test("deletes a second heading-ID post leaving the others intact")
|
||
func deleteSecondPost() throws {
|
||
let result = try writer.deletePost(timestamp: "2025-01-11T12:00:00+01:00", from: feed)
|
||
#expect(!result.contains("2025-01-11T12:00:00+01:00"))
|
||
#expect(!result.contains("Second post body."))
|
||
#expect(result.contains("First post body."))
|
||
#expect(result.contains("2025-01-12T08:00:00+01:00"))
|
||
}
|
||
|
||
@Test("deletes a post identified by :ID: property")
|
||
func deleteByPropertyID() throws {
|
||
let result = try writer.deletePost(timestamp: "2025-01-12T08:00:00+01:00", from: feed)
|
||
#expect(!result.contains("2025-01-12T08:00:00+01:00"))
|
||
#expect(result.contains("First post body."))
|
||
#expect(result.contains("Second post body."))
|
||
}
|
||
|
||
@Test("throws postNotFound for unknown timestamp")
|
||
func throwsOnMissingPost() throws {
|
||
#expect(throws: PostWriterError.postNotFound) {
|
||
try writer.deletePost(timestamp: "1999-01-01T00:00:00+00:00", from: feed)
|
||
}
|
||
}
|
||
|
||
@Test("deleted post leaves no triple newlines")
|
||
func noTripleNewlinesAfterDelete() throws {
|
||
let result = try writer.deletePost(timestamp: "2025-01-10T09:00:00+01:00", from: feed)
|
||
#expect(!result.contains("\n\n\n"))
|
||
}
|
||
|
||
@Test("global headers are preserved after delete")
|
||
func headersPreservedAfterDelete() throws {
|
||
let result = try writer.deletePost(timestamp: "2025-01-11T12:00:00+01:00", from: feed)
|
||
#expect(result.contains("#+TITLE: Mutation Test"))
|
||
#expect(result.contains("#+NICK: tester"))
|
||
#expect(result.contains("* Posts"))
|
||
}
|
||
|
||
@Test("deleting all posts leaves the Posts section header")
|
||
func postsSectionRemainsAfterAllDeleted() throws {
|
||
var content = feed
|
||
content = try writer.deletePost(timestamp: "2025-01-10T09:00:00+01:00", from: content)
|
||
content = try writer.deletePost(timestamp: "2025-01-11T12:00:00+01:00", from: content)
|
||
content = try writer.deletePost(timestamp: "2025-01-12T08:00:00+01:00", from: content)
|
||
#expect(content.contains("* Posts"))
|
||
#expect(!content.contains("** "))
|
||
#expect(!content.contains(":PROPERTIES:"))
|
||
}
|
||
}
|
||
|
||
@Suite("PostWriter – editPost")
|
||
struct PostWriterEditTests {
|
||
|
||
let writer = PostWriter()
|
||
|
||
@Test("replaces body of a heading-ID post")
|
||
func editHeadingIDPost() throws {
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-10T09:00:00+01:00",
|
||
newText: "Updated text.",
|
||
in: feed
|
||
)
|
||
#expect(result.contains("Updated text."))
|
||
#expect(!result.contains("First post body."))
|
||
#expect(result.contains("2025-01-10T09:00:00+01:00"))
|
||
}
|
||
|
||
@Test("replaces body of a property-ID post")
|
||
func editPropertyIDPost() throws {
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-12T08:00:00+01:00",
|
||
newText: "Now has a body.",
|
||
in: feed
|
||
)
|
||
#expect(result.contains("Now has a body."))
|
||
#expect(result.contains("2025-01-12T08:00:00+01:00"))
|
||
}
|
||
|
||
@Test("preserves all properties after edit")
|
||
func propertiesPreservedAfterEdit() throws {
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-10T09:00:00+01:00",
|
||
newText: "New body.",
|
||
in: feed
|
||
)
|
||
#expect(result.contains(":LANG: en"))
|
||
#expect(result.contains(":TAGS: swift"))
|
||
#expect(result.contains(":CLIENT: org-social-ios"))
|
||
}
|
||
|
||
@Test("other posts are untouched after edit")
|
||
func otherPostsUntouched() throws {
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-10T09:00:00+01:00",
|
||
newText: "Changed.",
|
||
in: feed
|
||
)
|
||
#expect(result.contains("Second post body."))
|
||
#expect(result.contains("2025-01-11T12:00:00+01:00"))
|
||
#expect(result.contains("2025-01-12T08:00:00+01:00"))
|
||
}
|
||
|
||
@Test("throws postNotFound for unknown timestamp")
|
||
func throwsOnMissingPost() throws {
|
||
#expect(throws: PostWriterError.postNotFound) {
|
||
try writer.editPost(timestamp: "1999-01-01T00:00:00+00:00", newText: "x", in: feed)
|
||
}
|
||
}
|
||
|
||
@Test("trimmed whitespace in new text")
|
||
func trimmedWhitespaceInNewText() throws {
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-11T12:00:00+01:00",
|
||
newText: " Trimmed. \n\n",
|
||
in: feed
|
||
)
|
||
#expect(result.contains("Trimmed."))
|
||
#expect(!result.contains(" Trimmed."))
|
||
}
|
||
|
||
@Test("re-parsing edited feed finds updated text")
|
||
func reparsingFindsUpdatedText() throws {
|
||
let feedURL = URL(string: "https://example.com/social.org")!
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-10T09:00:00+01:00",
|
||
newText: "Reparseable text.",
|
||
in: feed
|
||
)
|
||
let profile = OrgSocialParser().parse(result)
|
||
let edited = profile.posts.first { $0.timestamp == "2025-01-10T09:00:00+01:00" }
|
||
#expect(edited?.text == "Reparseable text.")
|
||
_ = feedURL
|
||
}
|
||
|
||
@Test("re-parsing deleted feed does not contain deleted post")
|
||
func reparsingAfterDelete() throws {
|
||
let result = try PostWriter().deletePost(timestamp: "2025-01-10T09:00:00+01:00", from: feed)
|
||
let profile = OrgSocialParser().parse(result)
|
||
#expect(!profile.posts.contains { $0.timestamp == "2025-01-10T09:00:00+01:00" })
|
||
#expect(profile.posts.contains { $0.timestamp == "2025-01-11T12:00:00+01:00" })
|
||
}
|
||
|
||
@Test("full edit: all editable fields are written")
|
||
func fullEditAllFields() throws {
|
||
let writer = PostWriter()
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-10T09:00:00+01:00",
|
||
in: feed,
|
||
newText: "Edited body with @mention placeholder",
|
||
newTimestamp: "2025-02-15T08:30:00+01:00",
|
||
lang: "es",
|
||
tags: "edit integration",
|
||
mood: "✨",
|
||
visibility: .mention
|
||
)
|
||
let profile = OrgSocialParser().parse(result)
|
||
// Old timestamp is gone, new one is there.
|
||
#expect(!profile.posts.contains { $0.timestamp == "2025-01-10T09:00:00+01:00" })
|
||
guard let edited = profile.posts.first(where: { $0.timestamp == "2025-02-15T08:30:00+01:00" }) else {
|
||
Issue.record("Edited post not found"); return
|
||
}
|
||
#expect(edited.text.contains("Edited body"))
|
||
#expect(edited.lang == "es")
|
||
#expect(edited.tags == ["edit", "integration"])
|
||
#expect(edited.mood == "✨")
|
||
#expect(edited.visibility == "mention")
|
||
}
|
||
|
||
@Test("full edit: clearing fields removes the properties")
|
||
func fullEditClearsFields() throws {
|
||
let writer = PostWriter()
|
||
// Start from a post that had LANG/TAGS, then clear everything.
|
||
let result = try writer.editPost(
|
||
timestamp: "2025-01-11T12:00:00+01:00",
|
||
in: feed,
|
||
newText: "Cleared",
|
||
newTimestamp: nil,
|
||
lang: nil,
|
||
tags: nil,
|
||
mood: nil,
|
||
visibility: .public
|
||
)
|
||
let profile = OrgSocialParser().parse(result)
|
||
guard let edited = profile.posts.first(where: { $0.timestamp == "2025-01-11T12:00:00+01:00" }) else {
|
||
Issue.record("Edited post not found"); return
|
||
}
|
||
#expect(edited.lang == nil)
|
||
#expect(edited.tags.isEmpty)
|
||
#expect(edited.mood == nil)
|
||
#expect(edited.visibility == nil)
|
||
}
|
||
}
|