field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }