TeraComposerAutosaveTests.swift (11367B)
1 import Foundation 2 @testable import TeraApp 3 import XCTest 4 5 @MainActor 6 final class TeraComposerAutosaveTests: XCTestCase { 7 private let scope = TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default") 8 9 func testSlowWriteCoalescesTenThousandEditsAndExplicitSaveAwaitsNewestReceipt() async throws { 10 let storage = ComposerTestStorage() 11 let first = await storage.pauseNext() 12 let composer = make(storage) 13 composer.change(form("first")) 14 await first.entered.wait() 15 let second = await storage.pauseNext() 16 for number in 1 ... 10000 { 17 composer.change(form("edit \(number)")) 18 } 19 let save = Task { try await composer.save(form("edit 10000")) } 20 let writesBefore = await storage.requests.count 21 XCTAssertEqual(writesBefore, 1) 22 XCTAssertNil(composer.acknowledged) 23 XCTAssertTrue(composer.isDirty) 24 await first.resume.open() 25 await second.entered.wait() 26 XCTAssertEqual(composer.acknowledged?.form.content, "first") 27 XCTAssertTrue(composer.isDirty) 28 XCTAssertEqual(composer.state, .saving) 29 let requests = await storage.requests 30 XCTAssertEqual(requests.map(\.editSequence), [1, 10001]) 31 XCTAssertEqual(requests.last?.expectedRevision, 1) 32 await second.resume.open() 33 let receipt = try await save.value 34 XCTAssertEqual(receipt.form.content, "edit 10000") 35 XCTAssertEqual(receipt.editSequence, 10001) 36 XCTAssertEqual(composer.state, .saved) 37 XCTAssertFalse(composer.isDirty) 38 } 39 40 func testReplacedScopesKeepOneWorkerAndDiscardLateOldReceipts() async throws { 41 let storage = ComposerTestStorage() 42 let old = await storage.pauseNext() 43 let composer = make(storage) 44 composer.change(form("old account")) 45 await old.entered.wait() 46 for number in 1 ... 100 { 47 composer.reset(scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "context-\(number)")) 48 composer.change(form("new \(number)")) 49 } 50 let next = await storage.pauseNext() 51 let writesBefore = await storage.requests.count 52 XCTAssertEqual(writesBefore, 1) 53 let save = Task { try await composer.save(form("new 100")) } 54 await old.resume.open() 55 await next.entered.wait() 56 XCTAssertNil(composer.acknowledged) 57 XCTAssertTrue(composer.isDirty) 58 await next.resume.open() 59 let receipt = try await save.value 60 XCTAssertEqual(receipt.scope.localNetworkID, "context-100") 61 XCTAssertEqual(receipt.form.content, "new 100") 62 let requests = await storage.requests 63 XCTAssertEqual(requests.count, 2) 64 XCTAssertNotEqual(requests.first?.id, requests.last?.id) 65 } 66 67 func testLostCommitCallbackRemainsFailedUntilExactReadReconcilesWithoutDuplicateWrite() async throws { 68 let storage = ComposerTestStorage() 69 await storage.failNext(.afterCommit) 70 let composer = make(storage) 71 await assertSaveFails(composer, form: form("persisted but unacknowledged")) 72 XCTAssertEqual(composer.state, .failed) 73 XCTAssertNil(composer.acknowledged) 74 let receipt = try await composer.save(form("persisted but unacknowledged")) 75 XCTAssertEqual(receipt.revision, 1) 76 XCTAssertEqual(composer.state, .saved) 77 let requests = await storage.requests 78 let reads = await storage.readCount 79 XCTAssertEqual(requests.count, 1) 80 XCTAssertEqual(reads, 1) 81 } 82 83 func testFailedWriteRetainsNewestEditAndRetriesSameIDAfterNotFound() async throws { 84 let storage = ComposerTestStorage() 85 await storage.failNext(.beforeCommit) 86 let composer = make(storage) 87 await assertSaveFails(composer, form: form("first")) 88 composer.change(form("newest")) 89 XCTAssertEqual(composer.state, .failed) 90 let receipt = try await composer.save(form("newest")) 91 XCTAssertEqual(receipt.form.content, "newest") 92 XCTAssertEqual(receipt.editSequence, 2) 93 let requests = await storage.requests 94 XCTAssertEqual(requests.count, 2) 95 XCTAssertEqual(requests.first?.id, requests.last?.id) 96 XCTAssertNil(requests.last?.expectedRevision) 97 } 98 99 func testWrongReceiptAndConcurrentWriterNeverBecomeSavedOrOverwriteTheirRevision() async { 100 let storage = ComposerTestStorage() 101 await storage.failNext(.differentWriter) 102 let composer = make(storage) 103 await assertSaveFails(composer, form: form("my edit")) 104 await assertSaveFails(composer, form: form("keep my newer edit")) 105 XCTAssertNil(composer.acknowledged) 106 XCTAssertTrue(composer.isDirty) 107 XCTAssertEqual(composer.state, .failed) 108 let requests = await storage.requests 109 XCTAssertEqual(requests.count, 1) 110 } 111 112 func testStopDoesNotClaimLateWriteSavedAndResumeReconcilesItsExactIdentity() async throws { 113 let storage = ComposerTestStorage() 114 let paused = await storage.pauseNext() 115 let composer = make(storage) 116 composer.change(form("background")) 117 await paused.entered.wait() 118 composer.stop() 119 await paused.resume.open() 120 await storage.completed.wait() 121 XCTAssertNil(composer.acknowledged) 122 XCTAssertEqual(composer.state, .unsaved) 123 composer.resume() 124 let receipt = try await composer.save(form("background")) 125 XCTAssertEqual(receipt.revision, 1) 126 let requests = await storage.requests 127 XCTAssertEqual(requests.count, 1) 128 XCTAssertEqual(composer.state, .saved) 129 } 130 131 func testNoOpEditsDoNotWriteAndUnavailableScopeCannotClaimSaved() async throws { 132 let storage = ComposerTestStorage() 133 let composer = make(storage) 134 let first = try await composer.save(form("same")) 135 for _ in 1 ... 1000 { 136 composer.change(form("same")) 137 } 138 let repeated = try await composer.save(form("same")) 139 XCTAssertEqual(first, repeated) 140 let requests = await storage.requests 141 XCTAssertEqual(requests.count, 1) 142 composer.reset(scope: nil) 143 await assertSaveFails(composer, form: form("not scoped")) 144 XCTAssertEqual(composer.state, .failed) 145 XCTAssertTrue(composer.isDirty) 146 } 147 148 func testStoreKeepsNewerTextAndSaveWaitsForTheLatestCoalescedReceipt() async throws { 149 let backend = try TeraScopeBackend() 150 let client = try await TeraScopeFixtures.client(backend) 151 let store = TeraAddStore(runtimeClient: client) 152 store.configure(snapshot: TeraScopeFixtures.snapshot()) 153 await store.start() 154 let first = await backend.pause(.composer) 155 store.updateForm(\.content, "first") 156 let save = Task { await store.save() } 157 await first.entered.wait() 158 let second = await backend.pause(.composer) 159 store.updateForm(\.content, "newest") 160 await first.resume.open() 161 await second.entered.wait() 162 XCTAssertEqual(store.form.content, "newest") 163 XCTAssertEqual(store.savedComposer?.form.content, "first") 164 XCTAssertNil(store.message) 165 XCTAssertTrue(store.isWorking) 166 await second.resume.open() 167 await save.value 168 XCTAssertEqual(store.savedComposer?.form.content, "newest") 169 XCTAssertEqual(store.form.content, "newest") 170 XCTAssertEqual(store.composerState, .saved) 171 XCTAssertFalse(store.isWorking) 172 store.stop() 173 _ = try await client.stop() 174 } 175 176 func testStoreDiscardsOldAccountReceiptAndPreservesEditingAcrossRelayReconfiguration() async throws { 177 let backend = try TeraScopeBackend() 178 let client = try await TeraScopeFixtures.client(backend) 179 let store = TeraAddStore(runtimeClient: client) 180 store.configure(snapshot: TeraScopeFixtures.snapshot()) 181 await store.start() 182 let first = await backend.pause(.composer) 183 store.updateForm(\.content, "old account") 184 let old = Task { await store.save() } 185 await first.entered.wait() 186 let updated = TeraScopeFixtures.snapshot(account: "b") 187 await backend.configure(updated) 188 store.configure(snapshot: updated) 189 await store.start() 190 store.updateForm(\.content, "new account") 191 let current = Task { await store.save() } 192 await first.resume.open() 193 await old.value 194 await current.value 195 let receipt = try XCTUnwrap(store.savedComposer) 196 XCTAssertEqual(receipt.scope.authorPublicKey, String(repeating: "b", count: 64)) 197 XCTAssertEqual(receipt.form.content, "new account") 198 store.configure(snapshot: TeraScopeFixtures.snapshot(account: "b", relay: "second", profile: "new profile")) 199 XCTAssertEqual(store.form.content, "new account") 200 await store.start() 201 await store.save() 202 XCTAssertEqual(store.savedComposer, receipt) 203 store.stop() 204 _ = try await client.stop() 205 } 206 207 private func make(_ storage: ComposerTestStorage) -> TeraComposerAutosave { 208 let composer = TeraComposerAutosave(persistence: storage.port, delay: {}) 209 composer.reset(scope: scope) 210 return composer 211 } 212 213 private func form(_ content: String) -> TeraComposerForm { 214 var form = TeraComposerForm(commandType: .createEvent) 215 form.content = content 216 form.eventStartDate = "2026-09-" 217 form.priceAmount = "12." 218 return form 219 } 220 221 private func assertSaveFails(_ composer: TeraComposerAutosave, form: TeraComposerForm) async { 222 do { 223 _ = try await composer.save(form) 224 XCTFail("Unconfirmed changes must not receive a saved acknowledgment.") 225 } catch { XCTAssertTrue(composer.isDirty) } 226 } 227 } 228 229 actor ComposerTestStorage { 230 enum Failure { case beforeCommit, afterCommit, differentWriter } 231 private var reserved = 0 232 private var values: [String: TeraComposerDraft] = [:] 233 private var pause: ResourceTestPause? 234 private var failure: Failure? 235 private(set) var requests: [TeraComposerSaveRequest] = [] 236 private(set) var readCount = 0 237 private(set) var cancelledWrites = 0 238 let completed = ResourceTestGate() 239 240 nonisolated var port: TeraComposerPersistence { 241 TeraComposerPersistence(reserve: { await self.reserve() }, save: { try await self.save($0) }, 242 load: { try await self.load($0, id: $1) }) 243 } 244 245 func pauseNext() -> ResourceTestPause { 246 let value = ResourceTestPause() 247 pause = value 248 return value 249 } 250 251 func failNext(_ failure: Failure) { 252 self.failure = failure 253 } 254 255 func reserve() -> String { 256 reserved += 1 257 return String(format: "%032x", reserved) 258 } 259 260 func save(_ request: TeraComposerSaveRequest) async throws -> TeraComposerSaveReceipt { 261 requests.append(request) 262 let pause = pause 263 self.pause = nil 264 let failure = failure 265 self.failure = nil 266 await pause?.wait() 267 if Task.isCancelled { 268 cancelledWrites += 1 269 } 270 if failure == .beforeCommit { 271 throw TeraComposerAcknowledgment.unconfirmed 272 } 273 guard values[request.id]?.revision == request.expectedRevision else { throw TeraComposerAcknowledgment.unconfirmed } 274 var form = request.form 275 if failure == .differentWriter { 276 form.content = "a concurrent writer" 277 } 278 let draft = TeraComposerDraft(scope: request.scope, id: request.id, revision: (request.expectedRevision ?? 0) + 1, 279 editSequence: request.editSequence, form: form) 280 values[request.id] = draft 281 await completed.open() 282 if failure == .afterCommit { 283 throw TeraComposerAcknowledgment.unconfirmed 284 } 285 return TeraComposerSaveReceipt(draft: draft, replayed: false) 286 } 287 288 func load(_ scope: TeraComposerScope, id: String) throws -> TeraComposerDraft { 289 readCount += 1 290 guard let draft = values[id], draft.scope == scope else { 291 throw TeraRuntimeFailure.local(operation: "test.composer", code: "composer_not_found", safeMessage: "Not saved.") 292 } 293 return draft 294 } 295 }