@@ -9,23 +9,30 @@ import SwiftDashSDK
99/// so without the SwiftData-backed cross-wallet filter the user
1010/// would have no signal that an orphan asset lock is waiting.
1111///
12- /// The filter has four pieces of business logic that can silently
12+ /// The filter has five pieces of business logic that can silently
1313/// regress:
1414///
15- /// 1. `statusRaw >= 1` floor — Built (0) is rejected (tight
16- /// crash window with no useful UX action), Broadcast (1) and
17- /// every later status are accepted. The row's *trailing
18- /// affordance* — spinner vs. Resume button — is staged on
19- /// `statusRaw >= 2` separately inside `ResumableRegistrationRow`,
20- /// not at the filter level.
21- /// 2. Anti-join on `(walletId, identityIndex)` — a slot taken
15+ /// 1. `fundingTypeRaw` in `0...2` — only generic identity-resume
16+ /// variants (IdentityRegistration / IdentityTopUp /
17+ /// IdentityTopUpNotBound) reach this surface. IdentityInvitation
18+ /// (3) is a bearer voucher restricted to the invitation reclaim
19+ /// flow; address and shielded-address top-ups (4 / 5) have their
20+ /// own surfaces; every other discriminator is rejected fail-closed.
21+ /// 2. `statusRaw` in `1...3` — Built (0) is rejected (tight
22+ /// crash window with no useful UX action), Broadcast (1),
23+ /// InstantSendLocked (2), and ChainLocked (3) are accepted.
24+ /// Since PR #4010 every row now renders a Resume button; the
25+ /// Broadcast row's Resume tap re-enters the finality wait via
26+ /// `resume_asset_lock`. Consumed (4) is rejected — see
27+ /// `testConsumedLocksAreHiddenFromResumableList`.
28+ /// 3. Anti-join on `(walletId, identityIndex)` — a slot taken
2229/// by a `PersistentIdentity` row removes its lock from the
2330/// surface even if the lock's own status would otherwise
2431/// qualify. This is what makes the filter "orphan-only".
25- /// 3 . The anti-join is **per-wallet**: slot 0 used on wallet A
32+ /// 4 . The anti-join is **per-wallet**: slot 0 used on wallet A
2633/// must not block slot 0 on wallet B. The `UsedSlot` value
2734/// key (walletId + slot) carries that scoping.
28- /// 4 . `identityIndexRaw` is stored as `Int32` but compared as
35+ /// 5 . `identityIndexRaw` is stored as `Int32` but compared as
2936/// `UInt32` via `UInt32(bitPattern:)`. A future cast change
3037/// (e.g. `UInt32(lockIdentityIndexRaw)` — which would trap on
3138/// negative inputs) must fail loudly here, not in production.
@@ -38,9 +45,9 @@ final class CreateIdentityResumableTests: XCTestCase {
3845 let walletId : Data
3946 let statusRaw : Int
4047 let identityIndexRaw : Int32
41- /// Defaults to 0 (IdentityRegistration) so the pre- existing cases
42- /// exercise the non-invitation path unchanged; the invitation
43- /// exclusion tests pass 3 (IdentityInvitation) explicitly .
48+ /// Defaults to `0` (IdentityRegistration) so existing status /
49+ /// anti-join / bridge tests stay focused on their invariant.
50+ /// Funding-type coverage lives in its own MARK block below .
4451 var fundingTypeRaw : Int = 0
4552 }
4653
@@ -63,10 +70,13 @@ final class CreateIdentityResumableTests: XCTestCase {
6370 XCTAssertTrue ( result. isEmpty)
6471 }
6572
66- /// Broadcast (1) must surface even though it's not actionable —
67- /// the row renders a spinner ("Waiting for InstantSendLock…")
68- /// until SPV delivers the IS lock and the persister flips it
69- /// to (2). Hiding (1) would create the UX asymmetry where a
73+ /// Broadcast (1) must surface. Since PR #4010 the trailing
74+ /// affordance is a Resume button for every row — tapping it
75+ /// on a Broadcast lock re-enters the finality wait via
76+ /// `resume_asset_lock` (re-broadcast + await ChainLock), which
77+ /// is what unstrands broadcast-but-interrupted locks (app
78+ /// killed mid-wait, or an aged lock whose IS quorum has
79+ /// rotated). Hiding (1) would create the UX asymmetry where a
7080 /// just-broadcast lock vanishes from the UI for ~10-30 seconds
7181 /// and then reappears at (2) — confusing rather than reassuring.
7282 func testAcceptsBroadcastForVisibility( ) {
@@ -97,7 +107,7 @@ final class CreateIdentityResumableTests: XCTestCase {
97107 /// delete-identity action removes the identity row WITHOUT the
98108 /// asset-lock row, which frees the slot in the anti-join. Without
99109 /// the upper bound (`<= 3`) on the filter, a Consumed lock would
100- /// re-surface as a perpetual-spinner row that can't be advanced
110+ /// re-surface as a Resume row that can't advance
101111 /// (`resume_asset_lock` rejects Consumed entries with
102112 /// "already Consumed — nothing to resume").
103113 func testConsumedLocksAreHiddenFromResumableList( ) {
@@ -109,50 +119,45 @@ final class CreateIdentityResumableTests: XCTestCase {
109119 XCTAssertEqual ( result, [ ] )
110120 }
111121
112- // MARK: - invitation-voucher exclusion
113-
114- /// An `IdentityInvitation` (fundingTypeRaw 3) lock is a shared bearer
115- /// voucher — it must NEVER surface as a resumable registration, even at
116- /// a fully actionable status with its nominal slot 0 unused (the exact
117- /// state of every unclaimed voucher). Resuming it would consume the
118- /// voucher into an unrelated local identity and kill the invitee's claim;
119- /// vouchers are recovered only via the Invitations screen's reclaim flow.
120- func testInvitationVouchersAreExcludedFromResumableList( ) {
121- for status in 1 ... 3 {
122- let voucher = FakeAssetLockRow (
123- walletId: walletA,
124- statusRaw: status,
125- identityIndexRaw: 0 ,
126- fundingTypeRaw: 3
127- )
128- let result = IdentitiesContentView . crossWalletResumableLocks (
129- in: [ voucher] ,
130- usedSlots: [ ]
131- )
132- XCTAssertEqual (
133- result, [ ] ,
134- " unclaimed invitation voucher at status \( status) must not be resumable "
135- )
136- }
137- }
122+ // MARK: - funding-type filter
123+
124+ /// The generic Resume surface is a strict whitelist. Invitation (3)
125+ /// is tested at every otherwise-resumable status because it is a
126+ /// shared bearer voucher: only the invitation reclaim flow may opt in
127+ /// to consuming it. Address variants (4 / 5) and unknown values must
128+ /// also fail closed rather than reaching IdentityCreate.
129+ func testFundingTypeGateAcceptsOnlyGenericIdentityResumeTypes( ) {
130+ let cases : [ ( fundingType: Int , statuses: [ Int ] , shouldSurface: Bool ) ] = [
131+ ( 0 , [ 2 ] , true ) ,
132+ ( 1 , [ 2 ] , true ) ,
133+ ( 2 , [ 2 ] , true ) ,
134+ ( 3 , Array ( 1 ... 3 ) , false ) , // IdentityInvitation
135+ ( 4 , [ 2 ] , false ) , // AssetLockAddressTopUp
136+ ( 5 , [ 2 ] , false ) , // AssetLockShieldedAddressTopUp
137+ ( - 1 , [ 2 ] , false ) ,
138+ ( 6 , [ 2 ] , false ) ,
139+ ( 99 , [ 2 ] , false ) ,
140+ ]
138141
139- /// The exclusion is invitation-specific: the other identity funding
140- /// types (0 registration / 1 top-up / 2 top-up-not-bound) keep
141- /// surfacing exactly as before.
142- func testNonInvitationFundingTypesStillSurface( ) {
143- let locks = ( 0 ... 2 ) . map { fundingType in
144- FakeAssetLockRow (
145- walletId: walletA,
146- statusRaw: 2 ,
147- identityIndexRaw: Int32 ( fundingType) , // distinct free slots
148- fundingTypeRaw: fundingType
149- )
142+ for testCase in cases {
143+ for status in testCase. statuses {
144+ let lock = FakeAssetLockRow (
145+ walletId: walletA,
146+ statusRaw: status,
147+ identityIndexRaw: 0 ,
148+ fundingTypeRaw: testCase. fundingType
149+ )
150+ let result = IdentitiesContentView . crossWalletResumableLocks (
151+ in: [ lock] ,
152+ usedSlots: [ ]
153+ )
154+ XCTAssertEqual (
155+ result,
156+ testCase. shouldSurface ? [ lock] : [ ] ,
157+ " fundingTypeRaw \( testCase. fundingType) at status \( status) "
158+ )
159+ }
150160 }
151- let result = IdentitiesContentView . crossWalletResumableLocks (
152- in: locks,
153- usedSlots: [ ]
154- )
155- XCTAssertEqual ( result, locks)
156161 }
157162
158163 // MARK: - anti-join
0 commit comments