Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,9 @@ Baseline.json
remote-config-defaults.plist
service-data.plist
GoogleService-Info.plist

/SourcePackages
/brainwallet/launch-resources/GoogleService-Info.plist
/brainwallet/launch-resources/remote-config-defaults.plist
/brainwallet/launch-resources/service-data.plist
# Storekit
/Storekit
*.storekit
Brainwallet-StoreKit-v1.storekit

# Storekit
*.storekit
/fastlane/report.xml
**/fastlane/test_output/
## Games protection
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "75A2A78F1DA5934300A983D8"
BuildableName = "Brainwallet.app"
BlueprintName = "brainwallet"
ReferencedContainer = "container:brainwallet.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "75A2A78F1DA5934300A983D8"
BuildableName = "Brainwallet.app"
BlueprintName = "brainwallet"
ReferencedContainer = "container:brainwallet.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../brainwallet/PreLaunchResources/bw-iap-config-v2.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "75A2A78F1DA5934300A983D8"
BuildableName = "Brainwallet.app"
BlueprintName = "brainwallet"
ReferencedContainer = "container:brainwallet.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,6 @@
identifier = "San Francisco, CA, USA"
referenceType = "1">
</LocationScenarioReference>
<StoreKitConfigurationFileReference
identifier = "../Private/general-purpose/BrainwalletiOSPrivateGeneralPurpose/brainwallet-private-general-purpose.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
57 changes: 57 additions & 0 deletions BrainwalletUnitTests/Launch Class Tests/SceneDelegateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// SceneDelegateTests.swift
// brainwallet
//
// Exercises SceneDelegate against the scene the test host app is running in.
//

@testable import brainwallet
import UIKit
import XCTest

final class SceneDelegateTests: XCTestCase {

private var sceneDelegate: SceneDelegate!

override func setUp() {
super.setUp()
sceneDelegate = SceneDelegate()
}

override func tearDown() {
sceneDelegate = nil
super.tearDown()
}

private func hostScene() throws -> UIWindowScene {
try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first,
"Test host has no connected UIWindowScene")
}

// MARK: - Lifecycle forwarding

func testSceneDidBecomeActiveClearsBadgeCount() throws {
UserDefaults.pendingNotificationBadgeCount = 7
sceneDelegate.sceneDidBecomeActive(try hostScene())
XCTAssertEqual(UserDefaults.pendingNotificationBadgeCount, 0)
}

func testSceneWillEnterForegroundDoesNotCrashWithoutWallet() throws {
// No wallet manager yet in the test host -> willEnterForeground early-returns
sceneDelegate.sceneWillEnterForeground(try hostScene())
}

func testSceneDidEnterBackgroundDoesNotCrash() throws {
sceneDelegate.sceneDidEnterBackground(try hostScene())
}

// Note: scene(_:willConnectTo:) and AppDelegate.configurationForConnecting cannot be unit tested,
// as UIScene.ConnectionOptions has no public initializer. Their wiring is covered by
// SceneLifecycleTests.testInfoPlistDeclaresSceneManifest.

// MARK: - URL handling

func testOpenURLContextsWithEmptySetIsIgnored() throws {
sceneDelegate.scene(try hostScene(), openURLContexts: [])
}
}
52 changes: 52 additions & 0 deletions BrainwalletUnitTests/Launch Class Tests/SceneLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// SceneLifecycleTests.swift
// brainwallet
//
// Guards the UIScene lifecycle adoption required by iOS 27
// (v3.10.1 crashed at launch with "NoSceneLifecycleAdoption").
//

@testable import brainwallet
import XCTest

final class SceneLifecycleTests: XCTestCase {

/// The Info.plist must declare a scene manifest pointing at SceneDelegate and
/// must not use the storyboard-created window.
func testInfoPlistDeclaresSceneManifest() throws {
let info = try XCTUnwrap(Bundle(for: AppDelegate.self).infoDictionary)
XCTAssertNil(info["UIMainStoryboardFile"], "Main storyboard window conflicts with scene lifecycle")

let manifest = try XCTUnwrap(info["UIApplicationSceneManifest"] as? [String: Any])
let configs = try XCTUnwrap(manifest["UISceneConfigurations"] as? [String: Any])
let appRole = try XCTUnwrap(configs["UIWindowSceneSessionRoleApplication"] as? [[String: Any]])
let first = try XCTUnwrap(appRole.first)

XCTAssertEqual(first["UISceneConfigurationName"] as? String, "Default Configuration")
let delegateName = try XCTUnwrap(first["UISceneDelegateClassName"] as? String)
XCTAssertTrue(delegateName.hasSuffix("SceneDelegate"))
XCTAssertNotNil(NSClassFromString(delegateName), "SceneDelegate class name must resolve at runtime")
}

func testAppDelegateNoLongerOwnsWindow() {
let appDelegate = AppDelegate()
XCTAssertFalse(appDelegate.responds(to: NSSelectorFromString("window")))
}

func testClearNotificationBadgeResetsPendingCount() {
UserDefaults.pendingNotificationBadgeCount = 5
AppDelegate().clearNotificationBadge()
XCTAssertEqual(UserDefaults.pendingNotificationBadgeCount, 0)
}

/// A URL that arrives before the wallet UI has launched is queued, not handled.
func testOpenURLBeforeLaunchIsQueued() throws {
let controller = ApplicationController()
let url = try XCTUnwrap(URL(string: "litecoin:LTC_ADDRESS"))
XCTAssertFalse(controller.open(url: url))
}

func testSceneDelegateStartsWithoutWindow() {
XCTAssertNil(SceneDelegate().window)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ class PushNotificationsTests: XCTestCase {
override func setUp() {
super.setUp()
appDelegate = AppDelegate()
appDelegate.window = UIWindow()
}

override func tearDown() {
Expand Down
98 changes: 98 additions & 0 deletions BrainwalletUnitTests/Settings Tests/NetworkFeeTierTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// NetworkFeeTierTests.swift
// brainwallet
//
// Covers the fee-tier math backing the Network Fee picker in
// SettingsLitecoinDetailView (litoshis-per-tier, nearest-tier lookup for a
// persisted preference, and litoshi -> fiat formatting).
//
// Copyright © 2025 Grunt Software, LTD. All rights reserved.

@testable import brainwallet
import XCTest

class NetworkFeeTierTests: XCTestCase {

private let fees = Fees(luxury: 66746, regular: 25000, economy: 8000, timestamp: 0)
private let usdRate = Rate(code: "USD", name: "US Dollar", rate: 60.0, lastTimestamp: Date())

// MARK: - litoshis(from:)

func testLitoshisFromMapsEachTierToItsFeesField() {
XCTAssertEqual(NetworkFeeTier.economy.litoshis(from: fees), fees.economy)
XCTAssertEqual(NetworkFeeTier.regular.litoshis(from: fees), fees.regular)
XCTAssertEqual(NetworkFeeTier.luxury.litoshis(from: fees), fees.luxury)
}

// MARK: - closest(to:in:)

func testClosestReturnsExactTierOnExactMatch() {
XCTAssertEqual(NetworkFeeTier.closest(to: Int(fees.economy), in: fees), .economy)
XCTAssertEqual(NetworkFeeTier.closest(to: Int(fees.regular), in: fees), .regular)
XCTAssertEqual(NetworkFeeTier.closest(to: Int(fees.luxury), in: fees), .luxury)
}

func testClosestPicksNearerNeighborBetweenTwoTiers() {
// Between economy (8000) and regular (25000); nearer to economy.
XCTAssertEqual(NetworkFeeTier.closest(to: 10_000, in: fees), .economy)
// Between regular (25000) and luxury (66746); nearer to luxury.
XCTAssertEqual(NetworkFeeTier.closest(to: 60_000, in: fees), .luxury)
}

func testClosestClampsToEconomyBelowLowestTier() {
XCTAssertEqual(NetworkFeeTier.closest(to: 0, in: fees), .economy)
}

func testClosestClampsToLuxuryAboveHighestTier() {
XCTAssertEqual(NetworkFeeTier.closest(to: 1_000_000, in: fees), .luxury)
}

func testClosestBreaksTiesTowardTheLowerTier() {
// Exactly halfway between economy (8000) and regular (25000) is 16500;
// a tie should favor the lower (economy) tier.
let tieFees = Fees(luxury: 30000, regular: 20000, economy: 10000, timestamp: 0)
XCTAssertEqual(NetworkFeeTier.closest(to: 15000, in: tieFees), .economy)
// Halfway between regular (20000) and luxury (30000) is 25000.
XCTAssertEqual(NetworkFeeTier.closest(to: 25000, in: tieFees), .regular)
}

func testClosestFallsBackToDefaultValuesWhenNothingIsStored() {
// UserDefaults.userSetPreferredNetworkFee falls back to Fees.usingDefaultValues.luxury
// when the user has never picked a fee — confirm that round-trips to .luxury.
let defaults = Fees.usingDefaultValues
let storedFee = Int(defaults.luxury)
XCTAssertEqual(NetworkFeeTier.closest(to: storedFee, in: defaults), .luxury)
}

// MARK: - formattedFiatAmount(litoshis:rate:)

func testFormattedFiatAmountConvertsLitoshisThroughLTCToFiat() {
// 100_000_000 litoshis == 1 LTC == $60 at a $60/LTC rate.
let result = NetworkFeeTier.formattedFiatAmount(litoshis: 100_000_000, rate: usdRate)
XCTAssertEqual(result, "60.000 USD")
}

func testFormattedFiatAmountForRealisticRegularFee() {
// 25000 litoshis at $60/LTC = 0.00025 LTC * 60 = 0.015.
let result = NetworkFeeTier.formattedFiatAmount(litoshis: fees.regular, rate: usdRate)
XCTAssertEqual(result, "0.015 USD")
}

func testFormattedFiatAmountIsEmptyWhenFeeIsZero() {
XCTAssertEqual(NetworkFeeTier.formattedFiatAmount(litoshis: 0, rate: usdRate), "")
}

func testFormattedFiatAmountIsEmptyWhenRateIsZero() {
let noRate = Rate(code: "", name: "", rate: 0.0, lastTimestamp: Date())
XCTAssertEqual(NetworkFeeTier.formattedFiatAmount(litoshis: fees.luxury, rate: noRate), "")
}

// MARK: - CaseIterable ordering (Picker tags rely on rawValue == 0/1/2)

func testAllCasesOrderMatchesPickerTagOrder() {
XCTAssertEqual(NetworkFeeTier.allCases.map(\.rawValue), [0, 1, 2])
XCTAssertEqual(NetworkFeeTier.economy.rawValue, 0)
XCTAssertEqual(NetworkFeeTier.regular.rawValue, 1)
XCTAssertEqual(NetworkFeeTier.luxury.rawValue, 2)
}
}
4 changes: 2 additions & 2 deletions BrainwalletUnitTests/Settings Tests/SettingsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class SettingsHelpersTests: XCTestCase {
// MARK: - Constants Tests

func testClosedRowHeight() {
XCTAssertEqual(closedRowHeight, 65.0, "closedRowHeight should be 65.0")
XCTAssertEqual(closedRowHeight, 50.0, "closedRowHeight should be 50.0")
}

func testToggleRowHeight() {
Expand Down Expand Up @@ -42,7 +42,7 @@ class SettingsHelpersTests: XCTestCase {
}

func testExpandArrowSize() {
XCTAssertEqual(expandArrowSize, 20.0, "expandArrowSize should be 20.0")
XCTAssertEqual(expandArrowSize, 16.0, "expandArrowSize should be 16.0")
}

func testPickerViewHeight() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// TrustedNodeProductIdTests.swift
// brainwallet
//
// Guards against the 3.10.0 App Review rejection (Guideline 2.1(b)): the app constant and the
// product list shipped in BrainwalletiOSPrivateGeneralPurpose drifted apart, so the Trusted
// Node IAP was filtered out of the purchase sheet.
//

import XCTest
@testable import brainwallet
import BrainwalletiOSPrivateGeneralPurpose

class TrustedNodeProductIdTests: XCTestCase {

/// Product id configured in App Store Connect for the Trusted Litecoin Node IAP.
private let appStoreConnectTrustedNodeId = "com.gruntsoftware.brainwallet.trusted_ltc_node_2"

private func bundledProductIds() throws -> [String: String] {
let bundle = Bundle(for: StoreKitManager.self)
let url = try XCTUnwrap(bundle.url(forResource: "BWProductsList", withExtension: "plist"),
"BWProductsList.plist missing from the general-purpose bundle")
let data = try Data(contentsOf: url)
return try XCTUnwrap(try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: String])
}

func testTrustedNodeConstantMatchesAppStoreConnect() {
XCTAssertEqual(trustedLTCNodeProductId, appStoreConnectTrustedNodeId)
}

func testTrustedNodeConstantIsInBundledProductList() throws {
let ids = try bundledProductIds()
XCTAssertEqual(ids["Trusted LTC Node Feature"], trustedLTCNodeProductId,
"BWProductsList.plist and trustedLTCNodeProductId must use the same product id")
}
}
2 changes: 1 addition & 1 deletion Modules/core
2 changes: 1 addition & 1 deletion Private/general-purpose
Loading
Loading