TeraComposerSaveSchedulingTests.swift (8571B)
1 import Foundation 2 @testable import TeraApp 3 import XCTest 4 5 @MainActor 6 final class TeraComposerSaveSchedulingTests: XCTestCase { 7 private let scope = TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default") 8 9 func testOrdinaryEditsCoalesceUntilDelayCompletes() async { 10 let storage = ComposerTestStorage() 11 let delay = ComposerCoalescingGate() 12 let composer = make(storage, delay: delay) 13 composer.change(form("first")) 14 await delay.entered.wait() 15 for index in 1 ... 1000 { 16 composer.change(form("edit \(index)")) 17 } 18 let before = await storage.requests 19 XCTAssertTrue(before.isEmpty) 20 XCTAssertTrue(composer.isDirty) 21 await delay.open() 22 await TeraScopeFixtures.eventually { composer.state == .saved } 23 let writes = await storage.requests 24 XCTAssertEqual(writes.count, 1) 25 XCTAssertEqual(writes.first?.form.content, "edit 1000") 26 XCTAssertEqual(writes.first?.editSequence, 1001) 27 } 28 29 func testExplicitSaveCancelsOnlyPendingDelayAndWaitsForReceipt() async throws { 30 let storage = ComposerTestStorage() 31 let write = await storage.pauseNext() 32 let delay = ComposerCoalescingGate() 33 let composer = make(storage, delay: delay) 34 composer.change(form("pending")) 35 await delay.entered.wait() 36 let save = Task { try await composer.save(form("newest")) } 37 await write.entered.wait() 38 let cancelled = await delay.cancelled 39 XCTAssertTrue(cancelled) 40 XCTAssertNil(composer.acknowledged) 41 XCTAssertTrue(composer.isDirty) 42 await write.resume.open() 43 let receipt = try await save.value 44 XCTAssertEqual(receipt.form.content, "newest") 45 XCTAssertEqual(receipt.revision, 1) 46 let writes = await storage.requests 47 XCTAssertEqual(writes.count, 1) 48 } 49 50 func testExplicitSaveDuringActiveWriteDrainsNewestWithoutCancellingWriter() async throws { 51 let storage = ComposerTestStorage() 52 let write = await storage.pauseNext() 53 let delay = ComposerCoalescingGate() 54 let composer = make(storage, delay: delay) 55 composer.change(form("first")) 56 await delay.entered.wait() 57 await delay.open() 58 await write.entered.wait() 59 let next = await storage.pauseNext() 60 let save = Task { try await composer.save(form("second")) } 61 await TeraScopeFixtures.eventually { composer.editSequence == 2 } 62 await write.resume.open() 63 await next.entered.wait() 64 XCTAssertEqual(composer.acknowledged?.form.content, "first") 65 XCTAssertTrue(composer.isDirty) 66 await next.resume.open() 67 let receipt = try await save.value 68 XCTAssertEqual(receipt.form.content, "second") 69 XCTAssertEqual(receipt.revision, 2) 70 let cancelledWrites = await storage.cancelledWrites 71 let waits = await delay.waitCount 72 XCTAssertEqual(cancelledWrites, 0) 73 XCTAssertEqual(waits, 1) 74 } 75 76 func testNoOpExplicitSaveDoesNotDisableNextAutosaveDelay() async throws { 77 let storage = ComposerTestStorage() 78 let delay = ComposerCoalescingGate() 79 let composer = make(storage, delay: delay) 80 let first = try await composer.save(form("saved")) 81 let same = try await composer.save(form("saved")) 82 XCTAssertEqual(first, same) 83 composer.change(form("later edit")) 84 await delay.entered.wait() 85 let writes = await storage.requests 86 XCTAssertEqual(writes.count, 1) 87 XCTAssertEqual(composer.acknowledged, first) 88 await delay.open() 89 await TeraScopeFixtures.eventually { composer.state == .saved } 90 XCTAssertEqual(composer.acknowledged?.form.content, "later edit") 91 } 92 93 func testStopCancelsDelayWithoutWritingAndResumeExplicitlySavesPendingEdit() async throws { 94 let storage = ComposerTestStorage() 95 let delay = ComposerCoalescingGate() 96 let composer = make(storage, delay: delay) 97 composer.change(form("pending")) 98 await delay.entered.wait() 99 composer.stop() 100 XCTAssertEqual(composer.state, .unsaved) 101 let writes = await storage.requests 102 XCTAssertTrue(writes.isEmpty) 103 XCTAssertNil(composer.acknowledged) 104 composer.resume() 105 let receipt = try await composer.save(form("pending")) 106 XCTAssertEqual(receipt.form.content, "pending") 107 XCTAssertEqual(receipt.revision, 1) 108 } 109 110 func testReplacingScopeDuringDelaySavesOnlyTheNewScope() async throws { 111 let storage = ComposerTestStorage() 112 let delay = ComposerCoalescingGate() 113 let composer = make(storage, delay: delay) 114 composer.change(form("old scope")) 115 await delay.entered.wait() 116 let next = TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "next") 117 composer.reset(scope: next) 118 let receipt = try await composer.save(form("new scope")) 119 XCTAssertEqual(receipt.scope, next) 120 XCTAssertEqual(receipt.form.content, "new scope") 121 let writes = await storage.requests 122 XCTAssertEqual(writes.count, 1) 123 XCTAssertEqual(writes.first?.scope, next) 124 } 125 126 func testExplicitFlushDoesNotHideANonCancellationFailureAtDelayBoundary() async { 127 let storage = ComposerTestStorage() 128 let pause = ResourceTestPause() 129 let composer = TeraComposerAutosave(persistence: storage.port, delay: { 130 await pause.wait() 131 throw TeraRuntimeFailure.local(operation: "test.delay", code: "protected_data_unavailable", 132 safeMessage: "Protected data unavailable.") 133 }) 134 composer.reset(scope: scope) 135 composer.change(form("pending")) 136 await pause.entered.wait() 137 let save = Task { try await composer.save(form("newest")) } 138 await TeraScopeFixtures.eventually { composer.editSequence == 2 } 139 await pause.resume.open() 140 do { 141 _ = try await save.value 142 XCTFail("Only the coalescing cancellation may be bypassed.") 143 } catch { 144 XCTAssertEqual(TeraAddPresentation.failure(for: error)?.code, "protected_data_unavailable") 145 } 146 XCTAssertEqual(composer.state, .failed) 147 XCTAssertTrue(composer.isDirty) 148 XCTAssertNil(composer.acknowledged) 149 let writes = await storage.requests 150 XCTAssertTrue(writes.isEmpty) 151 } 152 153 func testSaveKeepsFlushingWhenAcknowledgmentStartsASuccessorWorker() async throws { 154 let storage = ComposerTestStorage() 155 let delay = ComposerCoalescingGate() 156 let composer = make(storage, delay: delay) 157 var addedSuccessor = false 158 composer.stateChanged = { state in 159 guard state == .saved, !addedSuccessor else { return } 160 addedSuccessor = true 161 composer.change(self.form("successor edit")) 162 } 163 defer { composer.stateChanged = { _ in } } 164 let completed = expectation(description: "Explicit Save drains the successor without batching") 165 let save = Task { 166 defer { completed.fulfill() } 167 return try await composer.save(form("first edit")) 168 } 169 await fulfillment(of: [completed], timeout: 2) 170 // Release even on a failed expectation so a regression cannot strand work. 171 await delay.open() 172 let receipt = try await save.value 173 XCTAssertTrue(addedSuccessor) 174 XCTAssertEqual(receipt.form.content, "successor edit") 175 XCTAssertEqual(receipt.revision, 2) 176 XCTAssertEqual(composer.state, .saved) 177 XCTAssertFalse(composer.isDirty) 178 let writes = await storage.requests 179 let cancelledWrites = await storage.cancelledWrites 180 XCTAssertEqual(writes.count, 2) 181 XCTAssertEqual(writes.first?.id, writes.last?.id) 182 XCTAssertEqual(cancelledWrites, 0) 183 } 184 185 private func make(_ storage: ComposerTestStorage, delay: ComposerCoalescingGate) -> TeraComposerAutosave { 186 let composer = TeraComposerAutosave(persistence: storage.port, delay: { try await delay.wait() }) 187 composer.reset(scope: scope) 188 return composer 189 } 190 191 private func form(_ content: String) -> TeraComposerForm { 192 var form = TeraComposerForm(commandType: .createUpdate) 193 form.content = content 194 return form 195 } 196 } 197 198 actor ComposerCoalescingGate { 199 let entered = ResourceTestGate() 200 private var continuation: CheckedContinuation<Void, Error>? 201 private var finished = false 202 private(set) var cancelled = false 203 private(set) var waitCount = 0 204 205 func wait() async throws { 206 waitCount += 1 207 await entered.open() 208 try await withTaskCancellationHandler { 209 try Task.checkCancellation() 210 guard !finished else { return } 211 try await withCheckedThrowingContinuation { continuation = $0 } 212 } onCancel: { 213 Task { await self.cancel() } 214 } 215 } 216 217 func open() { 218 finished = true 219 continuation?.resume() 220 continuation = nil 221 } 222 223 private func cancel() { 224 cancelled = true 225 finished = true 226 continuation?.resume(throwing: CancellationError()) 227 continuation = nil 228 } 229 }