field_ios

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

TeraDraftRecoveryTests.swift (10182B)


      1 import Foundation
      2 @testable import TeraApp
      3 import XCTest
      4 
      5 @MainActor
      6 final class TeraDraftRecoveryTests: XCTestCase {
      7   private let scope = TeraComposerScope(authorPublicKey: String(repeating: "a", count: 64), localNetworkID: "default")
      8 
      9   func testRestoredCounterOverflowFailsWithoutWritingOrClaimingSaved() async throws {
     10     let storage = ComposerTestStorage()
     11     let composer = TeraComposerAutosave(persistence: storage.port, delay: {})
     12     composer.reset(scope: scope)
     13     let draft = TeraComposerDraft(scope: scope, id: String(repeating: "1", count: 32), revision: 7,
     14                                   editSequence: .max, form: form("saved"))
     15     try composer.restore(draft)
     16     XCTAssertEqual(composer.acknowledged, draft)
     17     XCTAssertFalse(composer.isDirty)
     18     composer.change(form("keep this edit"))
     19     XCTAssertTrue(composer.isDirty)
     20     XCTAssertEqual(composer.state, .failed)
     21     do {
     22       _ = try await composer.save(form("keep this edit"))
     23       XCTFail("Exhausted edit sequences cannot be saved.")
     24     } catch {}
     25     let writes = await storage.requests
     26     XCTAssertTrue(writes.isEmpty)
     27     XCTAssertEqual(composer.acknowledged, draft)
     28   }
     29 
     30   func testInvalidRestoreRetainsAcknowledgmentAndOldWriteCannotReplaceRestoredIdentity() async throws {
     31     let storage = ComposerTestStorage()
     32     let composer = TeraComposerAutosave(persistence: storage.port, delay: {})
     33     composer.reset(scope: scope)
     34     let pause = await storage.pauseNext()
     35     composer.change(form("old write"))
     36     await pause.entered.wait()
     37     let restored = TeraComposerDraft(scope: scope, id: String(repeating: "2", count: 32), revision: 4,
     38                                      editSequence: 5, form: form("restored"))
     39     try composer.restore(restored)
     40     for invalid in [
     41       TeraComposerDraft(scope: scope, id: String(repeating: "0", count: 32), revision: 1, editSequence: 1, form: form("invalid")),
     42       TeraComposerDraft(scope: scope, id: restored.id, revision: 0, editSequence: 1, form: form("invalid")),
     43       TeraComposerDraft(scope: scope, id: restored.id, revision: .max, editSequence: 1, form: form("invalid")),
     44       TeraComposerDraft(scope: scope, id: restored.id, revision: 1, editSequence: 0, form: form("invalid")),
     45       TeraComposerDraft(scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "foreign"),
     46                         id: restored.id, revision: 1, editSequence: 1, form: form("invalid")),
     47     ] {
     48       XCTAssertThrowsError(try composer.restore(invalid))
     49       XCTAssertEqual(composer.acknowledged, restored)
     50     }
     51     await pause.resume.open()
     52     await storage.completed.wait()
     53     let confirmed = try await composer.save(restored.form)
     54     XCTAssertEqual(confirmed, restored)
     55     XCTAssertEqual(composer.state, .saved)
     56     let writes = await storage.requests
     57     XCTAssertEqual(writes.count, 1)
     58   }
     59 
     60   func testMissingForeignAndLateSelectionsKeepCurrentEditing() async throws {
     61     let backend = try TeraScopeBackend()
     62     let client = try await TeraScopeFixtures.client(backend)
     63     let store = TeraAddStore(runtimeClient: client)
     64     store.configure(snapshot: TeraScopeFixtures.snapshot())
     65     await store.start()
     66     store.updateForm(\.content, "keep current editing")
     67     await store.save()
     68     let original = try XCTUnwrap(store.savedComposer)
     69     let missing = await store.reopenSaved(.composer(String(repeating: "3", count: 32)))
     70     XCTAssertFalse(missing)
     71     XCTAssertEqual(store.form.content, "keep current editing")
     72     XCTAssertEqual(store.savedComposer, original)
     73     let foreign = try await client.saveComposer(request: TeraComposerSaveRequest(
     74       scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "foreign"),
     75       id: String(repeating: "4", count: 32), expectedRevision: nil, editSequence: 1, form: form("foreign")
     76     ))
     77     let refused = await store.reopenSaved(.composer(foreign.draft.id))
     78     XCTAssertFalse(refused)
     79     XCTAssertEqual(store.savedComposer, original)
     80     let pause = await backend.pause(.composerLoad)
     81     let old = Task { await store.reopenSaved(.composer(original.id)) }
     82     await pause.entered.wait()
     83     let snapshot = TeraScopeFixtures.snapshot(account: "b")
     84     await backend.configure(snapshot)
     85     store.configure(snapshot: snapshot)
     86     await store.start()
     87     store.updateForm(\.content, "new account editing")
     88     await pause.resume.open()
     89     let applied = await old.value
     90     XCTAssertFalse(applied)
     91     XCTAssertEqual(store.form.content, "new account editing")
     92     XCTAssertNil(store.savedComposer)
     93     store.stop()
     94     _ = try await client.stop()
     95   }
     96 
     97   func testInventoryReplacesBoundedPagesPreservesRepairsAndIsolatesLegacyFailure() async throws {
     98     let backend = try TeraScopeBackend()
     99     let client = try await TeraScopeFixtures.client(backend)
    100     let entries = (1 ... 100).map { index in
    101       TeraComposerListEntry.draft(TeraComposerSummary(id: String(format: "%032x", index), revision: 1,
    102                                                       editSequence: 1, commandType: .createEvent,
    103                                                       createdAtUnixMilliseconds: 1, updatedAtUnixMilliseconds: 1))
    104     }
    105     let repairs: [TeraComposerListEntry] = [
    106       .repair(draftKey: String(repeating: "0", count: 32), revision: 1, reason: .corruptRecord),
    107       .repair(draftKey: String(repeating: "f", count: 32), revision: 2, reason: .unsupportedSchema),
    108     ]
    109     await backend.setComposerPage(TeraComposerPage(scope: scope, entries: entries, nextCursor: "next"))
    110     await backend.setComposerPage(TeraComposerPage(scope: scope, entries: repairs, nextCursor: nil), cursor: "next")
    111     let failure = await backend.pause(.legacyPage, fails: true)
    112     let recovery = TeraDraftRecoveryStore(client: client)
    113     recovery.configure(scope: scope)
    114     recovery.start()
    115     await failure.entered.wait()
    116     XCTAssertEqual(recovery.composers, entries)
    117     await failure.resume.open()
    118     await TeraScopeFixtures.eventually { !recovery.isLoading }
    119     XCTAssertNotNil(recovery.legacyError)
    120     XCTAssertNil(recovery.composerError)
    121     recovery.moreComposers()
    122     await TeraScopeFixtures.eventually { !recovery.isLoading }
    123     XCTAssertEqual(recovery.composers, repairs)
    124     XCTAssertNil(recovery.composerCursor)
    125     recovery.start()
    126     await TeraScopeFixtures.eventually { !recovery.isLoading }
    127     XCTAssertEqual(recovery.composers, entries)
    128     XCTAssertNil(recovery.legacyError)
    129     let limits = await backend.recoveryLimits
    130     XCTAssertTrue(limits.allSatisfy { $0 == 100 })
    131     recovery.stop()
    132     _ = try await client.stop()
    133   }
    134 
    135   func testOldInventoryPageCannotReplaceChangedScopeAndWorkRemainsBounded() async throws {
    136     let backend = try TeraScopeBackend()
    137     let client = try await TeraScopeFixtures.client(backend)
    138     let old = await backend.pause(.composerList)
    139     let recovery = TeraDraftRecoveryStore(client: client)
    140     recovery.configure(scope: scope)
    141     recovery.start()
    142     await old.entered.wait()
    143     for index in 1 ... 100 {
    144       recovery.configure(scope: TeraComposerScope(authorPublicKey: scope.authorPublicKey, localNetworkID: "context-\(index)"))
    145       recovery.start()
    146     }
    147     let counts = await backend.counts
    148     XCTAssertEqual(counts[.composerList], 1)
    149     await old.resume.open()
    150     await TeraScopeFixtures.eventually { !recovery.isLoading }
    151     let finalCounts = await backend.counts
    152     XCTAssertEqual(finalCounts[.composerList], 2)
    153     XCTAssertEqual(recovery.scope?.localNetworkID, "context-100")
    154     XCTAssertNil(recovery.composerError)
    155     XCTAssertTrue(recovery.composers.isEmpty)
    156     recovery.stop()
    157     _ = try await client.stop()
    158   }
    159 
    160   private func form(_ content: String) -> TeraComposerForm {
    161     var value = TeraComposerForm(commandType: .createEvent)
    162     value.content = content
    163     value.eventStartDate = "2026-09-"
    164     return value
    165   }
    166 
    167   func testLegacySelectionLoadsOriginalFormAndRejectsForeignAuthor() async throws {
    168     let backend = try TeraScopeBackend()
    169     let client = try await TeraScopeFixtures.client(backend)
    170     let store = TeraAddStore(runtimeClient: client)
    171     store.configure(snapshot: TeraScopeFixtures.snapshot())
    172     await store.start()
    173     let applied = await store.reopenSaved(.legacy("draft"))
    174     XCTAssertTrue(applied)
    175     XCTAssertEqual(store.activeDraft, TeraScopeFixtures.draft("old"))
    176     XCTAssertEqual(store.form, TeraScopeFixtures.draft("old").form)
    177     let snapshot = TeraScopeFixtures.snapshot(account: "b")
    178     await backend.configure(snapshot)
    179     store.configure(snapshot: snapshot)
    180     await store.start()
    181     store.updateForm(\.content, "other account editing")
    182     let refused = await store.reopenSaved(.legacy("draft"))
    183     XCTAssertFalse(refused)
    184     XCTAssertEqual(store.form.content, "other account editing")
    185     XCTAssertNil(store.activeDraft)
    186     store.stop()
    187     _ = try await client.stop()
    188   }
    189 
    190   func testLegacyRepairContinuationRemainsSeparateFromComposerPage() async throws {
    191     let backend = try TeraScopeBackend()
    192     let client = try await TeraScopeFixtures.client(backend)
    193     let first: [TeraLegacyDraftListEntry] = (1 ... 100).map { index in
    194       .repair(draftKey: String(format: "%032x", index), revision: 1, reason: .unsupportedSchema)
    195     }
    196     let last: [TeraLegacyDraftListEntry] = [.repair(draftKey: String(repeating: "0", count: 32), revision: 2, reason: .needsAttention)]
    197     await backend.setLegacyPage(TeraLegacyDraftPage(authorPublicKey: scope.authorPublicKey, entries: first, nextCursor: "next"))
    198     await backend.setLegacyPage(TeraLegacyDraftPage(authorPublicKey: scope.authorPublicKey, entries: last, nextCursor: nil), cursor: "next")
    199     let recovery = TeraDraftRecoveryStore(client: client)
    200     recovery.configure(scope: scope)
    201     recovery.start()
    202     await TeraScopeFixtures.eventually { !recovery.isLoading }
    203     XCTAssertEqual(recovery.legacy, first)
    204     recovery.moreLegacy()
    205     await TeraScopeFixtures.eventually { !recovery.isLoading }
    206     XCTAssertEqual(recovery.legacy, last)
    207     XCTAssertNil(recovery.legacyCursor)
    208     XCTAssertNil(recovery.composerError)
    209     XCTAssertNil(recovery.legacyError)
    210     XCTAssertTrue(recovery.composers.isEmpty)
    211     recovery.stop()
    212     _ = try await client.stop()
    213   }
    214 }