diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9ea7e30c..5dcc7502 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -108,7 +108,7 @@ jobs: # - { name: onchain_boost_receive_widgets, grep: "@onchain|@boost|@receive|@widgets" } # - { name: settings, grep: "@settings" } # - { name: security, grep: "@security" } - - { name: e2e, grep: '@send|@lnurl|@lightning|@backup|@onboarding|@onchain_1|@onchain_2|@numberpad|@widgets|@boost|@receive|@settings|@security' } + - { name: e2e, grep: '@transfer|@send|@lnurl|@lightning|@backup|@onboarding|@onchain_1|@onchain_2|@numberpad|@widgets|@boost|@receive|@settings|@security' } name: e2e-tests - ${{ matrix.shard.name }} diff --git a/Bitkit/Components/NumberPadTextField.swift b/Bitkit/Components/NumberPadTextField.swift index 92c282ec..f32c7f35 100644 --- a/Bitkit/Components/NumberPadTextField.swift +++ b/Bitkit/Components/NumberPadTextField.swift @@ -71,6 +71,8 @@ struct NumberPadTextField: View { } .contentShape(Rectangle()) .animation(springAnimation, value: currency.primaryDisplay) + .accessibilityElement(children: .contain) + .accessibilityIdentifierIfPresent(testIdentifier) } @ViewBuilder @@ -87,7 +89,6 @@ struct NumberPadTextField: View { + Text(viewModel.getPlaceholder(currency: currency)) .foregroundColor(isFocused ? .textSecondary : .textPrimary)) .font(.custom(Fonts.black, size: 44)) - .accessibilityIdentifierIfPresent(testIdentifier) } } } diff --git a/Bitkit/ViewModels/ChannelDetailsViewModel.swift b/Bitkit/ViewModels/ChannelDetailsViewModel.swift index f72f120f..91535e49 100644 --- a/Bitkit/ViewModels/ChannelDetailsViewModel.swift +++ b/Bitkit/ViewModels/ChannelDetailsViewModel.swift @@ -14,10 +14,12 @@ class ChannelDetailsViewModel: ObservableObject { @Published var error: Error? = nil private let coreService: CoreService + private let transferStorage: TransferStorage /// Private initializer for the singleton instance - private init(coreService: CoreService = .shared) { + private init(coreService: CoreService = .shared, transferStorage: TransferStorage = .shared) { self.coreService = coreService + self.transferStorage = transferStorage } /// Find a channel by ID, checking open channels, pending channels, pending orders, then closed channels @@ -124,15 +126,31 @@ class ChannelDetailsViewModel: ObservableObject { connections.append(contentsOf: channels.filter { !$0.isChannelReady }) } + // Only show pending orders that have been paid (aligns with Android/RN behavior) + let paidOrderIds: Set = { + guard let activeTransfers = try? transferStorage.getActiveTransfers() else { + return [] + } + return Set( + activeTransfers + .filter { $0.type.isToSpending() } + .compactMap(\.lspOrderId) + ) + }() + + if paidOrderIds.isEmpty { + return connections + } + // Create fake channels from pending orders guard let orders = try? await coreService.blocktank.orders(refresh: false) else { return connections } - let pendingOrders = orders.filter { order in - // Include orders that are created or paid but not yet opened - order.state2 == .created || order.state2 == .paid - } + let pendingOrders = Self.pendingOrders( + orders: orders, + paidOrderIds: paidOrderIds + ) for order in pendingOrders { let fakeChannel = createFakeChannel(from: order) @@ -142,6 +160,12 @@ class ChannelDetailsViewModel: ObservableObject { return connections } + static func pendingOrders(orders: [IBtOrder], paidOrderIds: Set) -> [IBtOrder] { + orders.filter { order in + paidOrderIds.contains(order.id) && (order.state2 == .created || order.state2 == .paid) + } + } + /// Creates a fake channel from a Blocktank order for UI display purposes private func createFakeChannel(from order: IBtOrder) -> ChannelDetails { return ChannelDetails( diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 5e9be557..0527b251 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -113,6 +113,10 @@ class TransferViewModel: ObservableObject { uiState.isAdvanced = true } + func displayOrder(for order: IBtOrder) -> IBtOrder { + uiState.order ?? order + } + func payOrder(order: IBtOrder, speed: TransactionSpeed) async throws { var fees = try? await coreService.blocktank.fees(refresh: true) if fees == nil { diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index d10a8e53..5a003947 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -70,12 +70,13 @@ struct LightningConnectionsView: View { .padding(.top, 16) ForEach(Array(pendingConnections.enumerated()), id: \.element.channelId) { index, channel in + let labelIndex = pendingConnections.count - index Button { navigation.navigate(.connectionDetail(channelId: channel.channelIdString)) } label: { VStack(spacing: 0) { HStack { - SubtitleText("\(t("lightning__connection")) \(index + 1)") + SubtitleText("\(t("lightning__connection")) \(labelIndex)") Spacer() Image("chevron") .resizable() @@ -109,12 +110,13 @@ struct LightningConnectionsView: View { .padding(.top, 16) ForEach(Array(openChannels.enumerated()), id: \.element.channelId) { index, channel in + let labelIndex = openChannels.count - index Button { navigation.navigate(.connectionDetail(channelId: channel.channelIdString)) } label: { VStack(spacing: 0) { HStack { - SubtitleText("\(t("lightning__connection")) \(index + 1)") + SubtitleText("\(t("lightning__connection")) \(labelIndex)") Spacer() Image("chevron") .resizable() @@ -147,12 +149,13 @@ struct LightningConnectionsView: View { .padding(.top, 16) ForEach(Array(closedChannels.enumerated()), id: \.element.channelId) { index, channel in + let labelIndex = closedChannels.count - index Button { navigation.navigate(.connectionDetail(channelId: channel.channelIdString)) } label: { VStack(spacing: 0) { HStack { - SubtitleText("\(t("lightning__connection")) \(index + 1)") + SubtitleText("\(t("lightning__connection")) \(labelIndex)") Spacer() Image("chevron") .resizable() diff --git a/Bitkit/Views/Transfer/FundManualAmountView.swift b/Bitkit/Views/Transfer/FundManualAmountView.swift index 57cab389..b633f182 100644 --- a/Bitkit/Views/Transfer/FundManualAmountView.swift +++ b/Bitkit/Views/Transfer/FundManualAmountView.swift @@ -57,6 +57,7 @@ struct FundManualAmountView: View { isDisabled: amountSats == 0, destination: FundManualConfirmView(lnPeer: lnPeer, amountSats: amountSats) ) + .accessibilityIdentifier("ExternalAmountContinue") } } .navigationBarHidden(true) diff --git a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift index 0c93dbe5..45d46010 100644 --- a/Bitkit/Views/Transfer/SavingsAvailabilityView.swift +++ b/Bitkit/Views/Transfer/SavingsAvailabilityView.swift @@ -33,6 +33,7 @@ struct SavingsAvailabilityView: View { CustomButton(title: t("common__continue")) { navigation.navigate(.savingsConfirm) } + .accessibilityIdentifier("AvailabilityContinue") } } .navigationBarHidden(true) diff --git a/Bitkit/Views/Transfer/SavingsProgressView.swift b/Bitkit/Views/Transfer/SavingsProgressView.swift index fb4e64fc..e119ae27 100644 --- a/Bitkit/Views/Transfer/SavingsProgressView.swift +++ b/Bitkit/Views/Transfer/SavingsProgressView.swift @@ -99,6 +99,7 @@ struct SavingsProgressContentView: View { .frame(width: 256, height: 256) .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess" : nil) } Spacer() @@ -109,6 +110,7 @@ struct SavingsProgressContentView: View { ) { navigation.reset() } + .accessibilityIdentifierIfPresent(progressState == .success ? "TransferSuccess-button" : nil) } .navigationBarHidden(true) .padding(.horizontal, 16) diff --git a/Bitkit/Views/Transfer/SettingUpView.swift b/Bitkit/Views/Transfer/SettingUpView.swift index 9249de87..81326603 100644 --- a/Bitkit/Views/Transfer/SettingUpView.swift +++ b/Bitkit/Views/Transfer/SettingUpView.swift @@ -169,6 +169,7 @@ struct SettingUpView: View { if isTransferring { SettingUpLoadingView() + .accessibilityIdentifier("LightningSettingUp") } else { Image("check") .resizable() @@ -176,6 +177,7 @@ struct SettingUpView: View { .frame(width: 256, height: 256) .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("TransferSuccess") } Spacer() @@ -188,6 +190,7 @@ struct SettingUpView: View { CustomButton(title: buttonTitle) { navigation.reset() } + .accessibilityIdentifier("TransferSuccess-button") } } .navigationBarHidden(true) diff --git a/Bitkit/Views/Transfer/SpendingAdvancedView.swift b/Bitkit/Views/Transfer/SpendingAdvancedView.swift index cbb9c6c5..33b6b85f 100644 --- a/Bitkit/Views/Transfer/SpendingAdvancedView.swift +++ b/Bitkit/Views/Transfer/SpendingAdvancedView.swift @@ -34,11 +34,15 @@ struct SpendingAdvancedView: View { DisplayText(t("lightning__spending_advanced__title"), accentColor: .purpleAccent) .fixedSize(horizontal: false, vertical: true) - NumberPadTextField(viewModel: amountViewModel, showConversion: false) - .onTapGesture { - amountViewModel.togglePrimaryDisplay(currency: currency) - } - .padding(.top, 32) + NumberPadTextField( + viewModel: amountViewModel, + showConversion: false, + testIdentifier: "SpendingAdvancedNumberField" + ) + .onTapGesture { + amountViewModel.togglePrimaryDisplay(currency: currency) + } + .padding(.top, 32) // Fee estimate HStack(spacing: 4) { @@ -91,6 +95,7 @@ struct SpendingAdvancedView: View { app.toast(error) } } + .accessibilityIdentifier("SpendingAdvancedContinue") } } .navigationBarHidden(true) @@ -118,18 +123,21 @@ struct SpendingAdvancedView: View { NumberPadActionButton(text: t("common__min")) { amountViewModel.updateFromSats(transfer.transferValues.minLspBalance, currency: currency) } + .accessibilityIdentifier("SpendingAdvancedMin") Spacer() NumberPadActionButton(text: t("common__default")) { amountViewModel.updateFromSats(transfer.transferValues.defaultLspBalance, currency: currency) } + .accessibilityIdentifier("SpendingAdvancedDefault") Spacer() NumberPadActionButton(text: t("common__max")) { amountViewModel.updateFromSats(transfer.transferValues.maxLspBalance, currency: currency) } + .accessibilityIdentifier("SpendingAdvancedMax") } } diff --git a/Bitkit/Views/Transfer/SpendingAmount.swift b/Bitkit/Views/Transfer/SpendingAmount.swift index fc7581f6..84bcfd4c 100644 --- a/Bitkit/Views/Transfer/SpendingAmount.swift +++ b/Bitkit/Views/Transfer/SpendingAmount.swift @@ -71,6 +71,7 @@ struct SpendingAmount: View { ) { await onContinue() } + .accessibilityIdentifier("SpendingAmountContinue") } .navigationBarHidden(true) .padding(.horizontal, 16) @@ -96,11 +97,13 @@ struct SpendingAmount: View { let quarter = UInt64(wallet.spendableOnchainBalanceSats) / 4 amountViewModel.updateFromSats(min(quarter, max), currency: currency) } + .accessibilityIdentifier("SpendingAmountQuarter") NumberPadActionButton(text: t("common__max")) { guard let max = maxTransferAmount else { return } amountViewModel.updateFromSats(max, currency: currency) } + .accessibilityIdentifier("SpendingAmountMax") } } diff --git a/Bitkit/Views/Transfer/SpendingConfirm.swift b/Bitkit/Views/Transfer/SpendingConfirm.swift index 99fa783a..2ba4721f 100644 --- a/Bitkit/Views/Transfer/SpendingConfirm.swift +++ b/Bitkit/Views/Transfer/SpendingConfirm.swift @@ -14,12 +14,16 @@ struct SpendingConfirm: View { @State private var hideSwipeButton = false @State private var transactionFee: UInt64 = 0 + private var currentOrder: IBtOrder { + transfer.displayOrder(for: order) + } + var lspFee: UInt64 { - order.feeSat - order.clientBalanceSat + currentOrder.feeSat - currentOrder.clientBalanceSat } var total: UInt64 { - order.feeSat + transactionFee + currentOrder.feeSat + transactionFee } var body: some View { @@ -47,7 +51,7 @@ struct SpendingConfirm: View { HStack { FeeDisplayRow( label: t("lightning__spending_confirm__amount"), - amount: order.clientBalanceSat + amount: currentOrder.clientBalanceSat ) .frame(maxWidth: .infinity) @@ -62,12 +66,14 @@ struct SpendingConfirm: View { if transfer.uiState.isAdvanced { LightningChannel( - capacity: order.lspBalanceSat + order.clientBalanceSat, - localBalance: order.clientBalanceSat, - remoteBalance: order.lspBalanceSat, + capacity: currentOrder.lspBalanceSat + currentOrder.clientBalanceSat, + localBalance: currentOrder.clientBalanceSat, + remoteBalance: currentOrder.lspBalanceSat, status: .open, showLabels: true ) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("SpendingConfirmChannel") .padding(.vertical, 16) } @@ -87,17 +93,20 @@ struct SpendingConfirm: View { HStack(spacing: 16) { CustomButton(title: t("common__learn_more"), size: .small) { - navigation.navigate(.transferLearnMore(order: order)) + navigation.navigate(.transferLearnMore(order: currentOrder)) } + .accessibilityIdentifier("SpendingConfirmMore") if transfer.uiState.isAdvanced { CustomButton(title: t("lightning__spending_confirm__default"), size: .small) { transfer.onDefaultClick() } + .accessibilityIdentifier("SpendingConfirmDefault") } else { CustomButton(title: t("common__advanced"), size: .small) { - navigation.navigate(.spendingAdvanced(order: order)) + navigation.navigate(.spendingAdvanced(order: currentOrder)) } + .accessibilityIdentifier("SpendingConfirmAdvanced") } } .frame(maxWidth: .infinity, alignment: .leading) @@ -125,7 +134,7 @@ struct SpendingConfirm: View { isPaying = true do { - try await transfer.payOrder(order: order, speed: .fast) + try await transfer.payOrder(order: currentOrder, speed: .fast) try await Task.sleep(nanoseconds: 1_000_000_000) navigation.navigate(.settingUp) @@ -148,13 +157,13 @@ struct SpendingConfirm: View { if let feeRates = try await coreService.blocktank.fees(refresh: true) { let fastFeeRate = TransactionSpeed.fast.getFeeRate(from: feeRates) - guard let address = order.payment?.onchain?.address else { + guard let address = currentOrder.payment?.onchain?.address else { throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) } let fee = try await wallet.calculateTotalFee( address: address, - amountSats: order.feeSat, + amountSats: currentOrder.feeSat, satsPerVByte: fastFeeRate ) diff --git a/Bitkit/Views/Transfer/TransferLearnMoreView.swift b/Bitkit/Views/Transfer/TransferLearnMoreView.swift index f8d2e1e8..93cd4eff 100644 --- a/Bitkit/Views/Transfer/TransferLearnMoreView.swift +++ b/Bitkit/Views/Transfer/TransferLearnMoreView.swift @@ -36,6 +36,7 @@ struct TransferLearnMoreView: View { dismiss() } .padding(.top, 32) + .accessibilityIdentifier("LiquidityContinue") } .navigationBarHidden(true) .padding(.horizontal, 16) diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift index eca166e0..e6b63a2d 100644 --- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift +++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift @@ -26,16 +26,19 @@ struct ReceiveEdit: View { SheetHeader(title: t("wallet__receive_specify"), showBackButton: true) VStack(alignment: .leading, spacing: 0) { - NumberPadTextField(viewModel: amountViewModel, isFocused: isAmountInputFocused) - .padding(.bottom, isAmountInputFocused ? 0 : 32) - .onTapGesture { - if isAmountInputFocused { - amountViewModel.togglePrimaryDisplay(currency: currency) - } else { - isAmountInputFocused = true - } + NumberPadTextField( + viewModel: amountViewModel, + isFocused: isAmountInputFocused, + testIdentifier: "ReceiveNumberPadTextField" + ) + .padding(.bottom, isAmountInputFocused ? 0 : 32) + .onTapGesture { + if isAmountInputFocused { + amountViewModel.togglePrimaryDisplay(currency: currency) + } else { + isAmountInputFocused = true } - .accessibilityIdentifier("ReceiveNumberPadTextField") + } if !isAmountInputFocused { CaptionMText(t("wallet__note")) diff --git a/Bitkit/Views/Wallets/SavingsWalletView.swift b/Bitkit/Views/Wallets/SavingsWalletView.swift index a4fc5f03..17b23304 100644 --- a/Bitkit/Views/Wallets/SavingsWalletView.swift +++ b/Bitkit/Views/Wallets/SavingsWalletView.swift @@ -89,6 +89,7 @@ struct SavingsWalletView: View { navigation.navigate(.spendingIntro) } } + .accessibilityIdentifier("TransferToSpending") } } diff --git a/Bitkit/Views/Wallets/SpendingWalletView.swift b/Bitkit/Views/Wallets/SpendingWalletView.swift index 339eb9e6..da748630 100644 --- a/Bitkit/Views/Wallets/SpendingWalletView.swift +++ b/Bitkit/Views/Wallets/SpendingWalletView.swift @@ -89,6 +89,7 @@ struct SpendingWalletView: View { navigation.navigate(.savingsIntro) } } + .accessibilityIdentifier("TransferToSavings") } } diff --git a/BitkitTests/ChannelDetailsViewModelTests.swift b/BitkitTests/ChannelDetailsViewModelTests.swift new file mode 100644 index 00000000..7dec670a --- /dev/null +++ b/BitkitTests/ChannelDetailsViewModelTests.swift @@ -0,0 +1,72 @@ +import BitkitCore +import XCTest + +@testable import Bitkit + +final class ChannelDetailsViewModelTests: XCTestCase { + @MainActor + func testPendingOrdersFiltersByPaidIdsAndState() { + let createdPaid = makeOrder(id: "createdPaid", state2: .created) + let paidPaid = makeOrder(id: "paidPaid", state2: .paid) + let createdUnpaid = makeOrder(id: "createdUnpaid", state2: .created) + let executedPaid = makeOrder(id: "executedPaid", state2: .executed) + let expiredPaid = makeOrder(id: "expiredPaid", state2: .expired) + + let orders = [createdPaid, paidPaid, createdUnpaid, executedPaid, expiredPaid] + let paidOrderIds: Set = ["createdPaid", "paidPaid", "executedPaid", "expiredPaid"] + + let result = ChannelDetailsViewModel.pendingOrders( + orders: orders, + paidOrderIds: paidOrderIds + ) + + let ids = Set(result.map(\.id)) + XCTAssertEqual(ids, ["createdPaid", "paidPaid"]) + } + + private func makeOrder(id: String, state2: BtOrderState2) -> IBtOrder { + IBtOrder( + id: id, + state: .created, + state2: state2, + feeSat: 1000, + networkFeeSat: 2483, + serviceFeeSat: 1520, + lspBalanceSat: 50000, + clientBalanceSat: 85967, + zeroConf: false, + zeroReserve: false, + clientNodeId: "node123", + channelExpiryWeeks: 52, + channelExpiresAt: "2025-03-14T10:30:00Z", + orderExpiresAt: "2024-03-21T15:45:00Z", + channel: nil, + lspNode: .init(alias: "", pubkey: "", connectionStrings: [], readonly: nil), + lnurl: nil, + payment: IBtPayment( + state: .created, + state2: .created, + paidSat: 0, + bolt11Invoice: IBtBolt11Invoice( + request: "lnbc...", + state: .pending, + expiresAt: "2024-03-21T15:45:00Z", + updatedAt: "2024-03-14T08:20:00Z" + ), + onchain: IBtOnchainTransactions( + address: "bc1q...", + confirmedSat: 0, + requiredConfirmations: 3, + transactions: [] + ), + isManuallyPaid: nil, + manualRefunds: nil + ), + couponCode: nil, + source: nil, + discount: nil, + updatedAt: "2024-03-14T08:20:00Z", + createdAt: "2024-03-14T08:15:00Z" + ) + } +} diff --git a/BitkitTests/TransferViewModelTests.swift b/BitkitTests/TransferViewModelTests.swift new file mode 100644 index 00000000..2a0acedd --- /dev/null +++ b/BitkitTests/TransferViewModelTests.swift @@ -0,0 +1,68 @@ +import BitkitCore +import XCTest + +@testable import Bitkit + +final class TransferViewModelTests: XCTestCase { + @MainActor + func testDisplayOrderPrefersUiStateOrder() { + let viewModel = TransferViewModel() + let baseOrder = makeOrder(id: "base", clientBalanceSat: 100_000, lspBalanceSat: 50000) + let updatedOrder = makeOrder(id: "updated", clientBalanceSat: 150_000, lspBalanceSat: 75000) + + let fallback = viewModel.displayOrder(for: baseOrder) + XCTAssertEqual(fallback.id, baseOrder.id) + XCTAssertEqual(fallback.clientBalanceSat, baseOrder.clientBalanceSat) + + viewModel.uiState.order = updatedOrder + let result = viewModel.displayOrder(for: baseOrder) + XCTAssertEqual(result.id, updatedOrder.id) + XCTAssertEqual(result.clientBalanceSat, updatedOrder.clientBalanceSat) + } + + private func makeOrder(id: String, clientBalanceSat: UInt64, lspBalanceSat: UInt64) -> IBtOrder { + IBtOrder( + id: id, + state: .created, + state2: .created, + feeSat: 1000, + networkFeeSat: 2483, + serviceFeeSat: 1520, + lspBalanceSat: lspBalanceSat, + clientBalanceSat: clientBalanceSat, + zeroConf: false, + zeroReserve: false, + clientNodeId: "node123", + channelExpiryWeeks: 52, + channelExpiresAt: "2025-03-14T10:30:00Z", + orderExpiresAt: "2024-03-21T15:45:00Z", + channel: nil, + lspNode: .init(alias: "", pubkey: "", connectionStrings: [], readonly: nil), + lnurl: nil, + payment: IBtPayment( + state: .created, + state2: .created, + paidSat: 0, + bolt11Invoice: IBtBolt11Invoice( + request: "lnbc...", + state: .pending, + expiresAt: "2024-03-21T15:45:00Z", + updatedAt: "2024-03-14T08:20:00Z" + ), + onchain: IBtOnchainTransactions( + address: "bc1q...", + confirmedSat: 0, + requiredConfirmations: 3, + transactions: [] + ), + isManuallyPaid: nil, + manualRefunds: nil + ), + couponCode: nil, + source: nil, + discount: nil, + updatedAt: "2024-03-14T08:20:00Z", + createdAt: "2024-03-14T08:15:00Z" + ) + } +}