field_ios

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

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 }