diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2013516 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,103 @@ +name: Tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + unit-tests: + name: Unit Tests + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + + - name: Show Xcode version + run: xcodebuild -version + + - name: Build and Test + run: | + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -resultBundlePath TestResults \ + -enableCodeCoverage YES \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + | xcpretty --color --report junit + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: TestResults + + - name: Upload Coverage Report + uses: actions/upload-artifact@v4 + if: success() + with: + name: coverage-report + path: TestResults + + ui-tests: + name: UI Tests + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + + - name: Build and Run UI Tests + run: | + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -only-testing:MacTornUITests \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + | xcpretty --color + continue-on-error: true + + build: + name: Build Release + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer + + - name: Build Release + run: | + xcodebuild build \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -configuration Release \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + + - name: Archive Build + run: | + xcodebuild archive \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -archivePath build/MacTorn.xcarchive \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + || echo "Archive step optional" diff --git a/MacTorn/MacTorn.xcodeproj/project.pbxproj b/MacTorn/MacTorn.xcodeproj/project.pbxproj index 83b0b3e..7c5becb 100644 --- a/MacTorn/MacTorn.xcodeproj/project.pbxproj +++ b/MacTorn/MacTorn.xcodeproj/project.pbxproj @@ -27,8 +27,41 @@ AAA00018 /* FactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10019 /* FactionView.swift */; }; AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; }; AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.swift */; }; + AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.swift */; }; +/* Unit Tests */ + BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; }; + BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; }; + BBB00003 /* TornAPIFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10003 /* TornAPIFixtures.swift */; }; + BBB00004 /* BarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10004 /* BarTests.swift */; }; + BBB00005 /* TravelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10005 /* TravelTests.swift */; }; + BBB00006 /* StatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10006 /* StatusTests.swift */; }; + BBB00007 /* ChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10007 /* ChainTests.swift */; }; + BBB00008 /* TornResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10008 /* TornResponseTests.swift */; }; + BBB00009 /* WatchlistItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10009 /* WatchlistItemTests.swift */; }; + BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; }; + BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; }; + BBB00012 /* AppStateWatchlistTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10012 /* AppStateWatchlistTests.swift */; }; +/* UI Tests */ + CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + BBB20001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AAA00000 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AAA40000; + remoteInfo = MacTorn; + }; + CCC20001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AAA00000 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AAA40000; + remoteInfo = MacTorn; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ AAA10001 /* MacTornApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornApp.swift; sourceTree = ""; }; AAA10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -51,7 +84,25 @@ AAA10019 /* FactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactionView.swift; sourceTree = ""; }; AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = ""; }; AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = ""; }; + AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = ""; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* Unit Test Files */ + BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = ""; }; + BBB10002 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; + BBB10003 /* TornAPIFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TornAPIFixtures.swift; sourceTree = ""; }; + BBB10004 /* BarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarTests.swift; sourceTree = ""; }; + BBB10005 /* TravelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelTests.swift; sourceTree = ""; }; + BBB10006 /* StatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTests.swift; sourceTree = ""; }; + BBB10007 /* ChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainTests.swift; sourceTree = ""; }; + BBB10008 /* TornResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TornResponseTests.swift; sourceTree = ""; }; + BBB10009 /* WatchlistItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistItemTests.swift; sourceTree = ""; }; + BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = ""; }; + BBB10011 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = ""; }; + BBB10012 /* AppStateWatchlistTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWatchlistTests.swift; sourceTree = ""; }; + BBB10000 /* MacTornTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* UI Test Files */ + CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = ""; }; + CCC10000 /* MacTornUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +113,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BBB20000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CCC20000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -69,6 +134,8 @@ isa = PBXGroup; children = ( AAA30001 /* MacTorn */, + BBB30000 /* MacTornTests */, + CCC30000 /* MacTornUITests */, AAA30002 /* Products */, ); sourceTree = ""; @@ -83,6 +150,7 @@ AAA30004 /* ViewModels */, AAA30005 /* Views */, AAA30007 /* Utilities */, + AAA30008 /* Networking */, ); path = MacTorn; sourceTree = ""; @@ -91,6 +159,8 @@ isa = PBXGroup; children = ( AAA10000 /* MacTorn.app */, + BBB10000 /* MacTornTests.xctest */, + CCC10000 /* MacTornUITests.xctest */, ); name = Products; sourceTree = ""; @@ -149,6 +219,75 @@ path = Utilities; sourceTree = ""; }; + AAA30008 /* Networking */ = { + isa = PBXGroup; + children = ( + AAA10022 /* NetworkSession.swift */, + ); + path = Networking; + sourceTree = ""; + }; +/* Unit Tests Groups */ + BBB30000 /* MacTornTests */ = { + isa = PBXGroup; + children = ( + BBB30001 /* Mocks */, + BBB30002 /* Fixtures */, + BBB30003 /* Models */, + BBB30004 /* ViewModels */, + ); + path = MacTornTests; + sourceTree = ""; + }; + BBB30001 /* Mocks */ = { + isa = PBXGroup; + children = ( + BBB10001 /* MockNetworkSession.swift */, + BBB10002 /* TestHelpers.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + BBB30002 /* Fixtures */ = { + isa = PBXGroup; + children = ( + BBB10003 /* TornAPIFixtures.swift */, + ); + path = Fixtures; + sourceTree = ""; + }; + BBB30003 /* Models */ = { + isa = PBXGroup; + children = ( + BBB10004 /* BarTests.swift */, + BBB10005 /* TravelTests.swift */, + BBB10006 /* StatusTests.swift */, + BBB10007 /* ChainTests.swift */, + BBB10008 /* TornResponseTests.swift */, + BBB10009 /* WatchlistItemTests.swift */, + BBB10010 /* MoneyDataTests.swift */, + ); + path = Models; + sourceTree = ""; + }; + BBB30004 /* ViewModels */ = { + isa = PBXGroup; + children = ( + BBB10011 /* AppStateTests.swift */, + BBB10012 /* AppStateWatchlistTests.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; +/* UI Tests Group */ + CCC30000 /* MacTornUITests */ = { + isa = PBXGroup; + children = ( + CCC10001 /* MacTornUITests.swift */, + ); + path = MacTornUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -169,6 +308,42 @@ productReference = AAA10000 /* MacTorn.app */; productType = "com.apple.product-type.application"; }; + BBB40000 /* MacTornTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BBB60000 /* Build configuration list for PBXNativeTarget "MacTornTests" */; + buildPhases = ( + BBB50000 /* Sources */, + BBB20000 /* Frameworks */, + BBB50001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BBB40001 /* PBXTargetDependency */, + ); + name = MacTornTests; + productName = MacTornTests; + productReference = BBB10000 /* MacTornTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + CCC40000 /* MacTornUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CCC60000 /* Build configuration list for PBXNativeTarget "MacTornUITests" */; + buildPhases = ( + CCC50000 /* Sources */, + CCC20000 /* Frameworks */, + CCC50001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CCC40001 /* PBXTargetDependency */, + ); + name = MacTornUITests; + productName = MacTornUITests; + productReference = CCC10000 /* MacTornUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -182,6 +357,14 @@ AAA40000 = { CreatedOnToolsVersion = 15.0; }; + BBB40000 = { + CreatedOnToolsVersion = 15.0; + TestTargetID = AAA40000; + }; + CCC40000 = { + CreatedOnToolsVersion = 15.0; + TestTargetID = AAA40000; + }; }; }; buildConfigurationList = AAA60001 /* Build configuration list for PBXProject "MacTorn" */; @@ -198,6 +381,8 @@ projectRoot = ""; targets = ( AAA40000 /* MacTorn */, + BBB40000 /* MacTornTests */, + CCC40000 /* MacTornUITests */, ); }; /* End PBXProject section */ @@ -211,6 +396,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + BBB50001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CCC50001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -237,11 +436,52 @@ AAA00018 /* FactionView.swift in Sources */, AAA00019 /* WatchlistView.swift in Sources */, AAA00020 /* PropertiesView.swift in Sources */, + AAA00021 /* NetworkSession.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BBB50000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BBB00001 /* MockNetworkSession.swift in Sources */, + BBB00002 /* TestHelpers.swift in Sources */, + BBB00003 /* TornAPIFixtures.swift in Sources */, + BBB00004 /* BarTests.swift in Sources */, + BBB00005 /* TravelTests.swift in Sources */, + BBB00006 /* StatusTests.swift in Sources */, + BBB00007 /* ChainTests.swift in Sources */, + BBB00008 /* TornResponseTests.swift in Sources */, + BBB00009 /* WatchlistItemTests.swift in Sources */, + BBB00010 /* MoneyDataTests.swift in Sources */, + BBB00011 /* AppStateTests.swift in Sources */, + BBB00012 /* AppStateWatchlistTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CCC50000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CCC00001 /* MacTornUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + BBB40001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AAA40000 /* MacTorn */; + targetProxy = BBB20001 /* PBXContainerItemProxy */; + }; + CCC40001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AAA40000 /* MacTorn */; + targetProxy = CCC20001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ AAA70000 /* Debug */ = { isa = XCBuildConfiguration; @@ -413,6 +653,78 @@ }; name = Release; }; +/* Unit Test Configurations */ + BBB70000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MacTorn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MacTorn"; + }; + name = Debug; + }; + BBB70001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MacTorn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MacTorn"; + }; + name = Release; + }; +/* UI Test Configurations */ + CCC70000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = MacTorn; + }; + name = Debug; + }; + CCC70001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = MacTorn; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -434,6 +746,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BBB60000 /* Build configuration list for PBXNativeTarget "MacTornTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BBB70000 /* Debug */, + BBB70001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CCC60000 /* Build configuration list for PBXNativeTarget "MacTornUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CCC70000 /* Debug */, + CCC70001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = AAA00000 /* Project object */; diff --git a/MacTorn/MacTorn/Networking/NetworkSession.swift b/MacTorn/MacTorn/Networking/NetworkSession.swift new file mode 100644 index 0000000..2f07f6d --- /dev/null +++ b/MacTorn/MacTorn/Networking/NetworkSession.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Protocol for network session abstraction to enable dependency injection and testing +protocol NetworkSession: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +/// Extension to make URLSession conform to NetworkSession +extension URLSession: NetworkSession {} diff --git a/MacTorn/MacTorn/ViewModels/AppState.swift b/MacTorn/MacTorn/ViewModels/AppState.swift index def912f..3b803c3 100644 --- a/MacTorn/MacTorn/ViewModels/AppState.swift +++ b/MacTorn/MacTorn/ViewModels/AppState.swift @@ -10,14 +10,14 @@ class AppState: ObservableObject { // MARK: - Persisted @AppStorage("apiKey") var apiKey: String = "" @AppStorage("refreshInterval") var refreshInterval: Int = 30 - + // MARK: - Published State @Published var data: TornResponse? @Published var lastUpdated: Date? @Published var errorMsg: String? @Published var isLoading: Bool = false @Published var notificationRules: [NotificationRule] = [] - + // MARK: - New Data Sources @Published var moneyData: MoneyData? @Published var battleStats: BattleStats? @@ -25,26 +25,30 @@ class AppState: ObservableObject { @Published var factionData: FactionData? @Published var propertiesData: [PropertyInfo]? @Published var watchlistItems: [WatchlistItem] = [] - + // MARK: - Update State @Published var updateAvailable: GitHubRelease? - + // MARK: - Managers let launchAtLogin = LaunchAtLoginManager() let shortcutsManager = ShortcutsManager() let updateManager = UpdateManager.shared - + + // MARK: - Networking (Dependency Injection for Testing) + private let session: NetworkSession + // MARK: - State Comparison private var previousBars: Bars? private var previousCooldowns: Cooldowns? private var previousTravel: Travel? private var previousChain: Chain? private var previousStatus: Status? - + // MARK: - Timer private var timerCancellable: AnyCancellable? - - init() { + + init(session: NetworkSession = URLSession.shared) { + self.session = session loadNotificationRules() loadWatchlist() // Polling and permissions moved to onAppear in UI @@ -126,7 +130,7 @@ class AppState: ObservableObject { do { var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { logger.error("Item \(itemId) HTTP Error: \(httpResponse.statusCode)") @@ -268,7 +272,7 @@ class AppState: ObservableObject { var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse @@ -452,7 +456,7 @@ class AppState: ObservableObject { do { var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - let (data, _) = try await URLSession.shared.data(for: request) + let (data, _) = try await session.data(for: request) // Check for Torn API error if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/MacTorn/MacTornTests/Fixtures/TornAPIFixtures.swift b/MacTorn/MacTornTests/Fixtures/TornAPIFixtures.swift new file mode 100644 index 0000000..7bf2ce7 --- /dev/null +++ b/MacTorn/MacTornTests/Fixtures/TornAPIFixtures.swift @@ -0,0 +1,241 @@ +import Foundation + +/// Sample JSON responses for testing +enum TornAPIFixtures { + + // MARK: - Full Response + + static let validFullResponse: [String: Any] = [ + "name": "TestPlayer", + "player_id": 123456, + "energy": [ + "current": 100, + "maximum": 150, + "increment": 5, + "interval": 300, + "ticktime": 60, + "fulltime": 600 + ], + "nerve": [ + "current": 50, + "maximum": 60, + "increment": 1, + "interval": 300, + "ticktime": 120, + "fulltime": 1800 + ], + "life": [ + "current": 7500, + "maximum": 7500, + "increment": 100, + "interval": 300, + "ticktime": 0, + "fulltime": 0 + ], + "happy": [ + "current": 5000, + "maximum": 10000, + "increment": 50, + "interval": 300, + "ticktime": 100, + "fulltime": 30000 + ], + "cooldowns": [ + "drug": 0, + "medical": 0, + "booster": 0 + ], + "travel": [ + "destination": "Torn", + "timestamp": 0, + "departed": 0, + "time_left": 0 + ], + "status": [ + "description": "Okay", + "details": "", + "state": "Okay", + "until": 0 + ], + "chain": [ + "current": 0, + "maximum": 10, + "timeout": 0, + "cooldown": 0 + ], + "events": [ + "1": [ + "timestamp": 1700000000, + "event": "You received a message from Someone", + "seen": 0 + ] + ], + "messages": [ + "1": [ + "name": "TestSender", + "type": "Private", + "title": "Test Message", + "timestamp": 1700000000, + "read": 0 + ] + ] + ] + + // MARK: - Bars + + static let energyFull: [String: Any] = [ + "current": 150, + "maximum": 150, + "increment": 5, + "interval": 300, + "ticktime": 0, + "fulltime": 0 + ] + + static let energyHalf: [String: Any] = [ + "current": 75, + "maximum": 150, + "increment": 5, + "interval": 300, + "ticktime": 150, + "fulltime": 4500 + ] + + static let energyEmpty: [String: Any] = [ + "current": 0, + "maximum": 150, + "increment": 5, + "interval": 300, + "ticktime": 300, + "fulltime": 9000 + ] + + // MARK: - Travel + + static let travelInTorn: [String: Any] = [ + "destination": "Torn", + "timestamp": 0, + "departed": 0, + "time_left": 0 + ] + + static let travelAbroad: [String: Any] = [ + "destination": "Mexico", + "timestamp": 0, + "departed": 0, + "time_left": 0 + ] + + static let travelTraveling: [String: Any] = [ + "destination": "Japan", + "timestamp": Int(Date().timeIntervalSince1970) + 600, + "departed": Int(Date().timeIntervalSince1970) - 300, + "time_left": 600 + ] + + // MARK: - Status + + static let statusOkay: [String: Any] = [ + "description": "Okay", + "details": "", + "state": "Okay", + "until": 0 + ] + + static let statusHospital: [String: Any] = [ + "description": "In hospital for 30 minutes", + "details": "Hospitalized by TestAttacker", + "state": "Hospital", + "until": Int(Date().timeIntervalSince1970) + 1800 + ] + + static let statusJail: [String: Any] = [ + "description": "In jail for 15 minutes", + "details": "Jailed for assault", + "state": "Jail", + "until": Int(Date().timeIntervalSince1970) + 900 + ] + + // MARK: - Chain + + static let chainInactive: [String: Any] = [ + "current": 0, + "maximum": 10, + "timeout": 0, + "cooldown": 0 + ] + + static let chainActive: [String: Any] = [ + "current": 25, + "maximum": 100, + "timeout": Int(Date().timeIntervalSince1970) + 300, + "cooldown": 0 + ] + + static let chainOnCooldown: [String: Any] = [ + "current": 0, + "maximum": 10, + "timeout": 0, + "cooldown": 3600 + ] + + // MARK: - Errors + + static let tornErrorInvalidKey: [String: Any] = [ + "error": [ + "code": 2, + "error": "Incorrect Key" + ] + ] + + static let tornErrorRateLimit: [String: Any] = [ + "error": [ + "code": 5, + "error": "Too many requests" + ] + ] + + // MARK: - Money + + static let moneyData: [String: Any] = [ + "money_onhand": 1000000, + "vault_amount": 50000000, + "points": 5000, + "company_funds": 100, + "cayman_bank": 100000000 + ] + + // MARK: - Market + + static let marketItemSuccess: [String: Any] = [ + "itemmarket": [ + "listings": [ + ["price": 1000, "amount": 5], + ["price": 1100, "amount": 3], + ["price": 1200, "amount": 10] + ] + ], + "bazaar": [ + ["cost": 950, "quantity": 2], + ["cost": 1050, "quantity": 7] + ] + ] + + static let marketItemNoListings: [String: Any] = [ + "itemmarket": [ + "listings": [] + ], + "bazaar": [] + ] + + // MARK: - Helper Methods + + static func toData(_ json: [String: Any]) throws -> Data { + return try JSONSerialization.data(withJSONObject: json) + } + + static func toString(_ json: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: json) + return String(data: data, encoding: .utf8)! + } +} diff --git a/MacTorn/MacTornTests/Mocks/MockNetworkSession.swift b/MacTorn/MacTornTests/Mocks/MockNetworkSession.swift new file mode 100644 index 0000000..fd4cef3 --- /dev/null +++ b/MacTorn/MacTornTests/Mocks/MockNetworkSession.swift @@ -0,0 +1,102 @@ +import Foundation +@testable import MacTorn + +/// Mock network session for testing API calls +final class MockNetworkSession: NetworkSession, @unchecked Sendable { + var mockData: Data? + var mockResponse: URLResponse? + var mockError: Error? + var requestedURLs: [URL] = [] + + init(mockData: Data? = nil, mockResponse: URLResponse? = nil, mockError: Error? = nil) { + self.mockData = mockData + self.mockResponse = mockResponse + self.mockError = mockError + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + if let url = request.url { + requestedURLs.append(url) + } + + if let error = mockError { + throw error + } + + let response = mockResponse ?? HTTPURLResponse( + url: request.url ?? URL(string: "https://api.torn.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + + return (mockData ?? Data(), response) + } + + // MARK: - Helper Methods + + /// Set up successful response with JSON data + func setSuccessResponse(json: [String: Any]) throws { + mockData = try JSONSerialization.data(withJSONObject: json) + mockResponse = HTTPURLResponse( + url: URL(string: "https://api.torn.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + mockError = nil + } + + /// Set up HTTP error response + func setHTTPError(statusCode: Int) { + mockData = Data() + mockResponse = HTTPURLResponse( + url: URL(string: "https://api.torn.com")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + mockError = nil + } + + /// Set up Torn API error response + func setTornAPIError(code: Int, message: String) throws { + let errorJSON: [String: Any] = [ + "error": [ + "code": code, + "error": message + ] + ] + mockData = try JSONSerialization.data(withJSONObject: errorJSON) + mockResponse = HTTPURLResponse( + url: URL(string: "https://api.torn.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) + mockError = nil + } + + /// Set up network error + func setNetworkError(_ error: Error) { + mockData = nil + mockResponse = nil + mockError = error + } + + /// Reset all mock data + func reset() { + mockData = nil + mockResponse = nil + mockError = nil + requestedURLs.removeAll() + } +} + +// MARK: - Test Errors + +enum MockNetworkError: Error { + case connectionFailed + case timeout + case noInternet +} diff --git a/MacTorn/MacTornTests/Mocks/TestHelpers.swift b/MacTorn/MacTornTests/Mocks/TestHelpers.swift new file mode 100644 index 0000000..fd3acbb --- /dev/null +++ b/MacTorn/MacTornTests/Mocks/TestHelpers.swift @@ -0,0 +1,101 @@ +import Foundation +import XCTest +@testable import MacTorn + +// MARK: - Test Helpers + +/// Creates a Bar instance for testing +func makeBar(current: Int = 100, maximum: Int = 150, increment: Double? = 5.0, interval: Int? = 300, ticktime: Int? = 60, fulltime: Int? = 600) -> Bar { + return Bar(current: current, maximum: maximum, increment: increment, interval: interval, ticktime: ticktime, fulltime: fulltime) +} + +/// Creates a Travel instance for testing +func makeTravel(destination: String? = "Torn", timestamp: Int? = nil, departed: Int? = nil, timeLeft: Int? = 0) -> Travel { + return Travel(destination: destination, timestamp: timestamp, departed: departed, timeLeft: timeLeft) +} + +/// Creates a Status instance for testing +func makeStatus(description: String? = "Okay", details: String? = nil, state: String? = "Okay", until: Int? = nil) -> Status { + return Status(description: description, details: details, state: state, until: until) +} + +/// Creates a Chain instance for testing +func makeChain(current: Int? = 0, maximum: Int? = 10, timeout: Int? = 0, cooldown: Int? = 0) -> Chain { + return Chain(current: current, maximum: maximum, timeout: timeout, cooldown: cooldown) +} + +/// Creates a WatchlistItem instance for testing +func makeWatchlistItem( + id: Int = 1, + name: String = "Test Item", + lowestPrice: Int = 1000, + lowestPriceQuantity: Int = 5, + secondLowestPrice: Int = 1100, + lastUpdated: Date? = Date(), + error: String? = nil +) -> WatchlistItem { + return WatchlistItem( + id: id, + name: name, + lowestPrice: lowestPrice, + lowestPriceQuantity: lowestPriceQuantity, + secondLowestPrice: secondLowestPrice, + lastUpdated: lastUpdated, + error: error + ) +} + +// MARK: - JSON Decoding Helpers + +extension XCTestCase { + /// Decode JSON string to a Decodable type + func decode(_ type: T.Type, from jsonString: String) throws -> T { + let data = jsonString.data(using: .utf8)! + return try JSONDecoder().decode(type, from: data) + } + + /// Decode JSON dictionary to a Decodable type + func decode(_ type: T.Type, from json: [String: Any]) throws -> T { + let data = try JSONSerialization.data(withJSONObject: json) + return try JSONDecoder().decode(type, from: data) + } +} + +// MARK: - Async Test Helpers + +extension XCTestCase { + /// Run async test with timeout + func runAsyncTest(timeout: TimeInterval = 5.0, testBlock: @escaping () async throws -> Void) { + let expectation = XCTestExpectation(description: "Async test") + + Task { + do { + try await testBlock() + expectation.fulfill() + } catch { + XCTFail("Async test failed with error: \(error)") + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: timeout) + } +} + +// MARK: - UserDefaults Test Helpers + +extension UserDefaults { + /// Create a mock UserDefaults for testing + static func createMockDefaults() -> UserDefaults { + let suiteName = "com.mactorn.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + return defaults + } + + /// Clear all data in UserDefaults + func clearAll() { + dictionaryRepresentation().keys.forEach { key in + removeObject(forKey: key) + } + } +} diff --git a/MacTorn/MacTornTests/Models/BarTests.swift b/MacTorn/MacTornTests/Models/BarTests.swift new file mode 100644 index 0000000..dd6c1ee --- /dev/null +++ b/MacTorn/MacTornTests/Models/BarTests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import MacTorn + +final class BarTests: XCTestCase { + + // MARK: - Percentage Calculation Tests + + func testPercentageCalculation_fullBar() { + let bar = Bar(current: 150, maximum: 150) + XCTAssertEqual(bar.percentage, 100.0, accuracy: 0.01) + } + + func testPercentageCalculation_halfBar() { + let bar = Bar(current: 75, maximum: 150) + XCTAssertEqual(bar.percentage, 50.0, accuracy: 0.01) + } + + func testPercentageCalculation_emptyBar() { + let bar = Bar(current: 0, maximum: 150) + XCTAssertEqual(bar.percentage, 0.0, accuracy: 0.01) + } + + func testPercentageCalculation_overFull() { + // Edge case: current > maximum (can happen with boosters) + let bar = Bar(current: 200, maximum: 150) + XCTAssertEqual(bar.percentage, 133.33, accuracy: 0.01) + } + + func testPercentageCalculation_zeroMaximum() { + // Edge case: division by zero protection + let bar = Bar(current: 0, maximum: 0) + XCTAssertEqual(bar.percentage, 0.0) + } + + // MARK: - Decoding Tests + + func testDecoding_fullBar() throws { + let json = TornAPIFixtures.energyFull + let bar = try decode(Bar.self, from: json) + + XCTAssertEqual(bar.current, 150) + XCTAssertEqual(bar.maximum, 150) + XCTAssertEqual(bar.increment, 5) + XCTAssertEqual(bar.interval, 300) + XCTAssertEqual(bar.ticktime, 0) + XCTAssertEqual(bar.fulltime, 0) + } + + func testDecoding_halfBar() throws { + let json = TornAPIFixtures.energyHalf + let bar = try decode(Bar.self, from: json) + + XCTAssertEqual(bar.current, 75) + XCTAssertEqual(bar.maximum, 150) + XCTAssertEqual(bar.percentage, 50.0, accuracy: 0.01) + } + + func testDecoding_emptyBar() throws { + let json = TornAPIFixtures.energyEmpty + let bar = try decode(Bar.self, from: json) + + XCTAssertEqual(bar.current, 0) + XCTAssertEqual(bar.maximum, 150) + XCTAssertEqual(bar.percentage, 0.0) + } + + // MARK: - Equatable Tests + + func testEquatable_sameBars() { + let bar1 = Bar(current: 100, maximum: 150) + let bar2 = Bar(current: 100, maximum: 150) + XCTAssertEqual(bar1, bar2) + } + + func testEquatable_differentBars() { + let bar1 = Bar(current: 100, maximum: 150) + let bar2 = Bar(current: 50, maximum: 150) + XCTAssertNotEqual(bar1, bar2) + } + + // MARK: - Edge Cases + + func testNegativeCurrent() { + // Edge case: negative current (shouldn't happen but handle gracefully) + let bar = Bar(current: -10, maximum: 150) + XCTAssertEqual(bar.percentage, -6.67, accuracy: 0.01) + } + + func testLargeNumbers() { + // Large numbers shouldn't cause overflow + let bar = Bar(current: 1000000, maximum: 10000000) + XCTAssertEqual(bar.percentage, 10.0, accuracy: 0.01) + } +} diff --git a/MacTorn/MacTornTests/Models/ChainTests.swift b/MacTorn/MacTornTests/Models/ChainTests.swift new file mode 100644 index 0000000..226c6f1 --- /dev/null +++ b/MacTorn/MacTornTests/Models/ChainTests.swift @@ -0,0 +1,183 @@ +import XCTest +@testable import MacTorn + +final class ChainTests: XCTestCase { + + // MARK: - isActive Tests + + func testIsActive_inactive() throws { + let json = TornAPIFixtures.chainInactive + let chain = try decode(Chain.self, from: json) + + XCTAssertFalse(chain.isActive) + } + + func testIsActive_active() throws { + let json = TornAPIFixtures.chainActive + let chain = try decode(Chain.self, from: json) + + XCTAssertTrue(chain.isActive) + } + + func testIsActive_zeroCurrent() throws { + let json: [String: Any] = [ + "current": 0, + "maximum": 10, + "timeout": Int(Date().timeIntervalSince1970) + 300, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + XCTAssertFalse(chain.isActive) + } + + func testIsActive_zeroTimeout() throws { + let json: [String: Any] = [ + "current": 25, + "maximum": 100, + "timeout": 0, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + XCTAssertFalse(chain.isActive) + } + + func testIsActive_nilValues() throws { + let json: [String: Any] = [:] + let chain = try decode(Chain.self, from: json) + XCTAssertFalse(chain.isActive) + } + + // MARK: - isOnCooldown Tests + + func testIsOnCooldown_no() throws { + let json = TornAPIFixtures.chainInactive + let chain = try decode(Chain.self, from: json) + + XCTAssertFalse(chain.isOnCooldown) + } + + func testIsOnCooldown_yes() throws { + let json = TornAPIFixtures.chainOnCooldown + let chain = try decode(Chain.self, from: json) + + XCTAssertTrue(chain.isOnCooldown) + } + + func testIsOnCooldown_zeroCooldown() throws { + let json: [String: Any] = [ + "current": 0, + "maximum": 10, + "timeout": 0, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + XCTAssertFalse(chain.isOnCooldown) + } + + func testIsOnCooldown_nilCooldown() throws { + let json: [String: Any] = [ + "current": 0, + "maximum": 10, + "timeout": 0 + ] + let chain = try decode(Chain.self, from: json) + XCTAssertFalse(chain.isOnCooldown) + } + + // MARK: - timeoutRemaining Tests + + func testTimeoutRemaining_noTimeout() throws { + let json: [String: Any] = [ + "current": 0, + "maximum": 10, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + XCTAssertEqual(chain.timeoutRemaining, 0) + } + + func testTimeoutRemaining_timeoutInPast() throws { + let pastTime = Int(Date().timeIntervalSince1970) - 1000 + let json: [String: Any] = [ + "current": 25, + "maximum": 100, + "timeout": pastTime, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + XCTAssertEqual(chain.timeoutRemaining, 0) + } + + func testTimeoutRemaining_timeoutInFuture() throws { + let futureTime = Int(Date().timeIntervalSince1970) + 1000 + let json: [String: Any] = [ + "current": 25, + "maximum": 100, + "timeout": futureTime, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + + // Should be approximately 1000, allow some tolerance + XCTAssertGreaterThan(chain.timeoutRemaining, 900) + XCTAssertLessThanOrEqual(chain.timeoutRemaining, 1000) + } + + // MARK: - Decoding Tests + + func testDecoding_activeChain() throws { + let json: [String: Any] = [ + "current": 50, + "maximum": 100, + "timeout": 1700000300, + "cooldown": 0 + ] + let chain = try decode(Chain.self, from: json) + + XCTAssertEqual(chain.current, 50) + XCTAssertEqual(chain.maximum, 100) + XCTAssertEqual(chain.timeout, 1700000300) + XCTAssertEqual(chain.cooldown, 0) + } + + func testDecoding_cooldownChain() throws { + let json = TornAPIFixtures.chainOnCooldown + let chain = try decode(Chain.self, from: json) + + XCTAssertEqual(chain.current, 0) + XCTAssertEqual(chain.cooldown, 3600) + XCTAssertTrue(chain.isOnCooldown) + } + + // MARK: - Equatable Tests + + func testEquatable() throws { + let json: [String: Any] = [ + "current": 25, + "maximum": 100, + "timeout": 1000, + "cooldown": 0 + ] + let chain1 = try decode(Chain.self, from: json) + let chain2 = try decode(Chain.self, from: json) + XCTAssertEqual(chain1, chain2) + } + + func testEquatable_different() throws { + let json1: [String: Any] = [ + "current": 25, + "maximum": 100, + "timeout": 1000, + "cooldown": 0 + ] + let json2: [String: Any] = [ + "current": 50, + "maximum": 100, + "timeout": 1000, + "cooldown": 0 + ] + let chain1 = try decode(Chain.self, from: json1) + let chain2 = try decode(Chain.self, from: json2) + XCTAssertNotEqual(chain1, chain2) + } +} diff --git a/MacTorn/MacTornTests/Models/MoneyDataTests.swift b/MacTorn/MacTornTests/Models/MoneyDataTests.swift new file mode 100644 index 0000000..6b6a460 --- /dev/null +++ b/MacTorn/MacTornTests/Models/MoneyDataTests.swift @@ -0,0 +1,103 @@ +import XCTest +@testable import MacTorn + +final class MoneyDataTests: XCTestCase { + + // MARK: - Decoding Tests + + func testDecoding_fullData() throws { + let data = try TornAPIFixtures.toData(TornAPIFixtures.moneyData) + let money = try JSONDecoder().decode(MoneyData.self, from: data) + + XCTAssertEqual(money.cash, 1000000) + XCTAssertEqual(money.vault, 50000000) + XCTAssertEqual(money.points, 5000) + XCTAssertEqual(money.tokens, 100) + XCTAssertEqual(money.cayman, 100000000) + } + + func testDecoding_withDefaults() throws { + // When fields are missing, should use defaults (0) + let json: [String: Any] = [ + "money_onhand": 500000 + // Other fields missing + ] + let data = try JSONSerialization.data(withJSONObject: json) + let money = try JSONDecoder().decode(MoneyData.self, from: data) + + XCTAssertEqual(money.cash, 500000) + XCTAssertEqual(money.vault, 0) // Default + XCTAssertEqual(money.points, 0) // Default + XCTAssertEqual(money.tokens, 0) // Default + XCTAssertEqual(money.cayman, 0) // Default + } + + func testDecoding_allMissing() throws { + let json: [String: Any] = [:] + let data = try JSONSerialization.data(withJSONObject: json) + let money = try JSONDecoder().decode(MoneyData.self, from: data) + + XCTAssertEqual(money.cash, 0) + XCTAssertEqual(money.vault, 0) + XCTAssertEqual(money.points, 0) + XCTAssertEqual(money.tokens, 0) + XCTAssertEqual(money.cayman, 0) + } + + // MARK: - Memberwise Initializer Tests + + func testMemberwiseInit() { + let money = MoneyData(cash: 100, vault: 200, points: 300, tokens: 400, cayman: 500) + + XCTAssertEqual(money.cash, 100) + XCTAssertEqual(money.vault, 200) + XCTAssertEqual(money.points, 300) + XCTAssertEqual(money.tokens, 400) + XCTAssertEqual(money.cayman, 500) + } + + func testDefaultInit() { + let money = MoneyData() + + XCTAssertEqual(money.cash, 0) + XCTAssertEqual(money.vault, 0) + XCTAssertEqual(money.points, 0) + XCTAssertEqual(money.tokens, 0) + XCTAssertEqual(money.cayman, 0) + } + + // MARK: - Large Numbers Tests + + func testLargeNumbers() throws { + let json: [String: Any] = [ + "money_onhand": 999999999999, + "vault_amount": 9999999999999, + "points": 100000, + "company_funds": 50000, + "cayman_bank": 99999999999999 + ] + let data = try JSONSerialization.data(withJSONObject: json) + let money = try JSONDecoder().decode(MoneyData.self, from: data) + + XCTAssertEqual(money.cash, 999999999999) + XCTAssertEqual(money.vault, 9999999999999) + XCTAssertEqual(money.points, 100000) + XCTAssertEqual(money.tokens, 50000) + XCTAssertEqual(money.cayman, 99999999999999) + } + + // MARK: - Encoding Tests + + func testEncodingRoundTrip() throws { + let original = MoneyData(cash: 1000, vault: 2000, points: 100, tokens: 50, cayman: 5000) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(MoneyData.self, from: data) + + XCTAssertEqual(original.cash, decoded.cash) + XCTAssertEqual(original.vault, decoded.vault) + XCTAssertEqual(original.points, decoded.points) + XCTAssertEqual(original.tokens, decoded.tokens) + XCTAssertEqual(original.cayman, decoded.cayman) + } +} diff --git a/MacTorn/MacTornTests/Models/StatusTests.swift b/MacTorn/MacTornTests/Models/StatusTests.swift new file mode 100644 index 0000000..447d89b --- /dev/null +++ b/MacTorn/MacTornTests/Models/StatusTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import MacTorn + +final class StatusTests: XCTestCase { + + // MARK: - isOkay Tests + + func testIsOkay_stateOkay() throws { + let json = TornAPIFixtures.statusOkay + let status = try decode(Status.self, from: json) + + XCTAssertTrue(status.isOkay) + } + + func testIsOkay_stateNil() throws { + let json: [String: Any] = [:] + let status = try decode(Status.self, from: json) + XCTAssertTrue(status.isOkay) + } + + func testIsOkay_inHospital() throws { + let json = TornAPIFixtures.statusHospital + let status = try decode(Status.self, from: json) + + XCTAssertFalse(status.isOkay) + } + + func testIsOkay_inJail() throws { + let json = TornAPIFixtures.statusJail + let status = try decode(Status.self, from: json) + + XCTAssertFalse(status.isOkay) + } + + // MARK: - isInHospital Tests + + func testIsInHospital_yes() throws { + let json = TornAPIFixtures.statusHospital + let status = try decode(Status.self, from: json) + + XCTAssertTrue(status.isInHospital) + XCTAssertFalse(status.isInJail) + } + + func testIsInHospital_no() throws { + let json = TornAPIFixtures.statusOkay + let status = try decode(Status.self, from: json) + + XCTAssertFalse(status.isInHospital) + } + + // MARK: - isInJail Tests + + func testIsInJail_yes() throws { + let json = TornAPIFixtures.statusJail + let status = try decode(Status.self, from: json) + + XCTAssertTrue(status.isInJail) + XCTAssertFalse(status.isInHospital) + } + + func testIsInJail_no() throws { + let json = TornAPIFixtures.statusOkay + let status = try decode(Status.self, from: json) + + XCTAssertFalse(status.isInJail) + } + + // MARK: - timeRemaining Tests + + func testTimeRemaining_noUntil() throws { + let json: [String: Any] = [ + "description": "Okay", + "state": "Okay" + ] + let status = try decode(Status.self, from: json) + XCTAssertEqual(status.timeRemaining, 0) + } + + func testTimeRemaining_untilInPast() throws { + let pastTime = Int(Date().timeIntervalSince1970) - 1000 + let json: [String: Any] = [ + "description": "In hospital", + "state": "Hospital", + "until": pastTime + ] + let status = try decode(Status.self, from: json) + XCTAssertEqual(status.timeRemaining, 0) + } + + func testTimeRemaining_untilInFuture() throws { + let futureTime = Int(Date().timeIntervalSince1970) + 1000 + let json: [String: Any] = [ + "description": "In hospital", + "state": "Hospital", + "until": futureTime + ] + let status = try decode(Status.self, from: json) + + // Should be approximately 1000, allow some tolerance for test execution time + XCTAssertGreaterThan(status.timeRemaining, 900) + XCTAssertLessThanOrEqual(status.timeRemaining, 1000) + } + + // MARK: - Decoding Tests + + func testDecoding_fullStatus() throws { + let json: [String: Any] = [ + "description": "In hospital for 30 minutes", + "details": "Hospitalized by SomePlayer", + "state": "Hospital", + "until": 1700000000 + ] + let status = try decode(Status.self, from: json) + + XCTAssertEqual(status.description, "In hospital for 30 minutes") + XCTAssertEqual(status.details, "Hospitalized by SomePlayer") + XCTAssertEqual(status.state, "Hospital") + XCTAssertEqual(status.until, 1700000000) + } + + // MARK: - Equatable Tests + + func testEquatable() throws { + let json: [String: Any] = [ + "description": "Okay", + "state": "Okay" + ] + let status1 = try decode(Status.self, from: json) + let status2 = try decode(Status.self, from: json) + XCTAssertEqual(status1, status2) + } + + func testEquatable_different() throws { + let json1: [String: Any] = [ + "description": "Okay", + "state": "Okay" + ] + let json2: [String: Any] = [ + "description": "Hospital", + "state": "Hospital", + "until": 1000 + ] + let status1 = try decode(Status.self, from: json1) + let status2 = try decode(Status.self, from: json2) + XCTAssertNotEqual(status1, status2) + } +} diff --git a/MacTorn/MacTornTests/Models/TornResponseTests.swift b/MacTorn/MacTornTests/Models/TornResponseTests.swift new file mode 100644 index 0000000..8010de3 --- /dev/null +++ b/MacTorn/MacTornTests/Models/TornResponseTests.swift @@ -0,0 +1,184 @@ +import XCTest +@testable import MacTorn + +final class TornResponseTests: XCTestCase { + + // MARK: - Full Decoding Tests + + func testDecoding_validFullResponse() throws { + let data = try TornAPIFixtures.toData(TornAPIFixtures.validFullResponse) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertEqual(response.name, "TestPlayer") + XCTAssertEqual(response.playerId, 123456) + XCTAssertNotNil(response.energy) + XCTAssertNotNil(response.nerve) + XCTAssertNotNil(response.life) + XCTAssertNotNil(response.happy) + XCTAssertNotNil(response.cooldowns) + XCTAssertNotNil(response.travel) + XCTAssertNotNil(response.status) + XCTAssertNotNil(response.chain) + XCTAssertNotNil(response.events) + XCTAssertNotNil(response.messages) + XCTAssertNil(response.error) + } + + // MARK: - Bars Computed Property Tests + + func testBars_allBarsPresent() throws { + let data = try TornAPIFixtures.toData(TornAPIFixtures.validFullResponse) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + let bars = response.bars + XCTAssertNotNil(bars) + XCTAssertEqual(bars?.energy.current, 100) + XCTAssertEqual(bars?.nerve.current, 50) + XCTAssertEqual(bars?.life.current, 7500) + XCTAssertEqual(bars?.happy.current, 5000) + } + + func testBars_missingBar() throws { + let json: [String: Any] = [ + "name": "TestPlayer", + "player_id": 123456, + "energy": TornAPIFixtures.energyFull, + "nerve": TornAPIFixtures.energyHalf + // Missing life and happy + ] + let data = try TornAPIFixtures.toData(json) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertNil(response.bars) // Should be nil because not all bars present + } + + // MARK: - Unread Messages Count Tests + + func testUnreadMessagesCount_noMessages() throws { + let json: [String: Any] = [ + "name": "TestPlayer" + // No messages + ] + let data = try TornAPIFixtures.toData(json) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertEqual(response.unreadMessagesCount, 0) + } + + func testUnreadMessagesCount_allRead() throws { + let json: [String: Any] = [ + "name": "TestPlayer", + "messages": [ + "1": ["name": "Sender1", "read": 1], + "2": ["name": "Sender2", "read": 1] + ] + ] + let data = try TornAPIFixtures.toData(json) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertEqual(response.unreadMessagesCount, 0) + } + + func testUnreadMessagesCount_someUnread() throws { + let json: [String: Any] = [ + "name": "TestPlayer", + "messages": [ + "1": ["name": "Sender1", "read": 0], + "2": ["name": "Sender2", "read": 1], + "3": ["name": "Sender3", "read": 0] + ] + ] + let data = try TornAPIFixtures.toData(json) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertEqual(response.unreadMessagesCount, 2) + } + + // MARK: - Recent Events Tests + + func testRecentEvents_noEvents() throws { + let json: [String: Any] = [ + "name": "TestPlayer" + ] + let data = try TornAPIFixtures.toData(json) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertTrue(response.recentEvents.isEmpty) + } + + func testRecentEvents_sortedByTimestamp() throws { + let json: [String: Any] = [ + "name": "TestPlayer", + "events": [ + "1": ["timestamp": 1000, "event": "Old event", "seen": 1], + "2": ["timestamp": 3000, "event": "Newest event", "seen": 0], + "3": ["timestamp": 2000, "event": "Middle event", "seen": 0] + ] + ] + let data = try TornAPIFixtures.toData(json) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + let events = response.recentEvents + XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0].timestamp, 3000) // Newest first + XCTAssertEqual(events[1].timestamp, 2000) + XCTAssertEqual(events[2].timestamp, 1000) // Oldest last + } + + // MARK: - Error Response Tests + + func testDecoding_errorResponse() throws { + let data = try TornAPIFixtures.toData(TornAPIFixtures.tornErrorInvalidKey) + let response = try JSONDecoder().decode(TornResponse.self, from: data) + + XCTAssertNotNil(response.error) + XCTAssertEqual(response.error?.code, 2) + XCTAssertEqual(response.error?.error, "Incorrect Key") + } + + // MARK: - TornEvent Tests + + func testTornEvent_cleanEvent() throws { + let json: [String: Any] = [ + "timestamp": 1700000000, + "event": "You received a message from SomePlayer.", + "seen": 0 + ] + let data = try JSONSerialization.data(withJSONObject: json) + let event = try JSONDecoder().decode(TornEvent.self, from: data) + + XCTAssertEqual(event.cleanEvent, "You received a message from SomePlayer.") + } + + func testTornEvent_date() throws { + let json: [String: Any] = [ + "timestamp": 1700000000, + "event": "Test event", + "seen": 0 + ] + let data = try JSONSerialization.data(withJSONObject: json) + let event = try JSONDecoder().decode(TornEvent.self, from: data) + + XCTAssertEqual(event.date, Date(timeIntervalSince1970: 1700000000)) + } + + // MARK: - TornMessage Tests + + func testTornMessage_decoding() throws { + let json: [String: Any] = [ + "name": "TestSender", + "type": "Private", + "title": "Hello World", + "timestamp": 1700000000, + "read": 0 + ] + let data = try JSONSerialization.data(withJSONObject: json) + let message = try JSONDecoder().decode(TornMessage.self, from: data) + + XCTAssertEqual(message.name, "TestSender") + XCTAssertEqual(message.type, "Private") + XCTAssertEqual(message.title, "Hello World") + XCTAssertEqual(message.timestamp, 1700000000) + XCTAssertEqual(message.read, 0) + } +} diff --git a/MacTorn/MacTornTests/Models/TravelTests.swift b/MacTorn/MacTornTests/Models/TravelTests.swift new file mode 100644 index 0000000..279cce4 --- /dev/null +++ b/MacTorn/MacTornTests/Models/TravelTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import MacTorn + +final class TravelTests: XCTestCase { + + // MARK: - isAbroad Tests + + func testIsAbroad_inTorn() throws { + let json = TornAPIFixtures.travelInTorn + let travel = try decode(Travel.self, from: json) + + XCTAssertFalse(travel.isAbroad) + } + + func testIsAbroad_abroadInMexico() throws { + let json = TornAPIFixtures.travelAbroad + let travel = try decode(Travel.self, from: json) + + XCTAssertTrue(travel.isAbroad) + } + + func testIsAbroad_travelingToDestination() throws { + let json = TornAPIFixtures.travelTraveling + let travel = try decode(Travel.self, from: json) + + // Still traveling, not yet abroad + XCTAssertFalse(travel.isAbroad) + } + + // MARK: - isTraveling Tests + + func testIsTraveling_notTraveling() throws { + let json = TornAPIFixtures.travelInTorn + let travel = try decode(Travel.self, from: json) + + XCTAssertFalse(travel.isTraveling) + } + + func testIsTraveling_traveling() throws { + let json = TornAPIFixtures.travelTraveling + let travel = try decode(Travel.self, from: json) + + XCTAssertTrue(travel.isTraveling) + } + + func testIsTraveling_abroadNotTraveling() throws { + let json = TornAPIFixtures.travelAbroad + let travel = try decode(Travel.self, from: json) + + XCTAssertFalse(travel.isTraveling) + } + + // MARK: - arrivalDate Tests + + func testArrivalDate_notTraveling() throws { + let json = TornAPIFixtures.travelInTorn + let travel = try decode(Travel.self, from: json) + + XCTAssertNil(travel.arrivalDate) + } + + func testArrivalDate_traveling() throws { + let json = TornAPIFixtures.travelTraveling + let travel = try decode(Travel.self, from: json) + + XCTAssertNotNil(travel.arrivalDate) + // Arrival should be in the future + XCTAssertGreaterThan(travel.arrivalDate!, Date()) + } + + // MARK: - Decoding Tests + + func testDecoding_allFields() throws { + let json: [String: Any] = [ + "destination": "Japan", + "timestamp": 1700000000, + "departed": 1699999000, + "time_left": 1000 + ] + let travel = try decode(Travel.self, from: json) + + XCTAssertEqual(travel.destination, "Japan") + XCTAssertEqual(travel.timestamp, 1700000000) + XCTAssertEqual(travel.departed, 1699999000) + XCTAssertEqual(travel.timeLeft, 1000) + } + + func testDecoding_nullFields() throws { + let json: [String: Any?] = [ + "destination": nil, + "timestamp": nil, + "departed": nil, + "time_left": nil + ] + let data = try JSONSerialization.data(withJSONObject: json) + let travel = try JSONDecoder().decode(Travel.self, from: data) + + XCTAssertNil(travel.destination) + XCTAssertNil(travel.timestamp) + XCTAssertNil(travel.departed) + XCTAssertNil(travel.timeLeft) + } + + // MARK: - Equatable Tests + + func testEquatable() throws { + let json: [String: Any] = [ + "destination": "Mexico", + "timestamp": 1000, + "departed": 500, + "time_left": 0 + ] + let travel1 = try decode(Travel.self, from: json) + let travel2 = try decode(Travel.self, from: json) + XCTAssertEqual(travel1, travel2) + } + + // MARK: - Edge Cases + + func testIsAbroad_nilDestination() throws { + let json: [String: Any] = [ + "time_left": 0 + ] + let travel = try decode(Travel.self, from: json) + XCTAssertFalse(travel.isAbroad) + } + + func testIsTraveling_nilTimeLeft() throws { + let json: [String: Any] = [ + "destination": "Mexico" + ] + let travel = try decode(Travel.self, from: json) + XCTAssertFalse(travel.isTraveling) + } +} diff --git a/MacTorn/MacTornTests/Models/WatchlistItemTests.swift b/MacTorn/MacTornTests/Models/WatchlistItemTests.swift new file mode 100644 index 0000000..89ba4d4 --- /dev/null +++ b/MacTorn/MacTornTests/Models/WatchlistItemTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import MacTorn + +final class WatchlistItemTests: XCTestCase { + + // MARK: - priceDifference Tests + + func testPriceDifference_normalCase() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 1000, + lowestPriceQuantity: 5, + secondLowestPrice: 1100, + lastUpdated: Date(), + error: nil + ) + + XCTAssertEqual(item.priceDifference, 100) + } + + func testPriceDifference_samePrices() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 1000, + lowestPriceQuantity: 5, + secondLowestPrice: 1000, + lastUpdated: Date(), + error: nil + ) + + XCTAssertEqual(item.priceDifference, 0) + } + + func testPriceDifference_noSecondPrice() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 1000, + lowestPriceQuantity: 5, + secondLowestPrice: 0, + lastUpdated: Date(), + error: nil + ) + + XCTAssertEqual(item.priceDifference, 0) + } + + func testPriceDifference_noLowestPrice() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 0, + lowestPriceQuantity: 0, + secondLowestPrice: 1100, + lastUpdated: Date(), + error: nil + ) + + XCTAssertEqual(item.priceDifference, 0) + } + + // MARK: - isLoading Tests + + func testIsLoading_loading() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 0, + lowestPriceQuantity: 0, + secondLowestPrice: 0, + lastUpdated: nil, + error: nil + ) + + XCTAssertTrue(item.isLoading) + } + + func testIsLoading_loaded() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 1000, + lowestPriceQuantity: 5, + secondLowestPrice: 1100, + lastUpdated: Date(), + error: nil + ) + + XCTAssertFalse(item.isLoading) + } + + func testIsLoading_hasError() { + let item = WatchlistItem( + id: 1, + name: "Test Item", + lowestPrice: 0, + lowestPriceQuantity: 0, + secondLowestPrice: 0, + lastUpdated: nil, + error: "No listings" + ) + + // Has error, so not loading + XCTAssertFalse(item.isLoading) + } + + // MARK: - Decoding Tests + + func testDecoding_fullItem() throws { + let json: [String: Any] = [ + "id": 123, + "name": "Xanax", + "lowestPrice": 850000, + "lowestPriceQuantity": 10, + "secondLowestPrice": 860000, + "lastUpdated": Date().timeIntervalSinceReferenceDate + ] + let data = try JSONSerialization.data(withJSONObject: json) + let item = try JSONDecoder().decode(WatchlistItem.self, from: data) + + XCTAssertEqual(item.id, 123) + XCTAssertEqual(item.name, "Xanax") + XCTAssertEqual(item.lowestPrice, 850000) + XCTAssertEqual(item.lowestPriceQuantity, 10) + XCTAssertEqual(item.secondLowestPrice, 860000) + } + + func testDecoding_legacyItemMissingFields() throws { + // Legacy items might not have all fields + let json: [String: Any] = [ + "id": 123, + "name": "Xanax" + // Missing price fields + ] + let data = try JSONSerialization.data(withJSONObject: json) + let item = try JSONDecoder().decode(WatchlistItem.self, from: data) + + XCTAssertEqual(item.id, 123) + XCTAssertEqual(item.name, "Xanax") + XCTAssertEqual(item.lowestPrice, 0) // Default + XCTAssertEqual(item.lowestPriceQuantity, 0) // Default + XCTAssertEqual(item.secondLowestPrice, 0) // Default + } + + func testDecoding_withError() throws { + let json: [String: Any] = [ + "id": 123, + "name": "Invalid Item", + "lowestPrice": 0, + "lowestPriceQuantity": 0, + "secondLowestPrice": 0, + "error": "Item not found" + ] + let data = try JSONSerialization.data(withJSONObject: json) + let item = try JSONDecoder().decode(WatchlistItem.self, from: data) + + XCTAssertEqual(item.error, "Item not found") + XCTAssertFalse(item.isLoading) + } + + // MARK: - Encoding Tests + + func testEncoding_roundTrip() throws { + let original = WatchlistItem( + id: 456, + name: "Donator Pack", + lowestPrice: 9500000, + lowestPriceQuantity: 3, + secondLowestPrice: 9600000, + lastUpdated: Date(), + error: nil + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(WatchlistItem.self, from: data) + + XCTAssertEqual(original.id, decoded.id) + XCTAssertEqual(original.name, decoded.name) + XCTAssertEqual(original.lowestPrice, decoded.lowestPrice) + XCTAssertEqual(original.lowestPriceQuantity, decoded.lowestPriceQuantity) + XCTAssertEqual(original.secondLowestPrice, decoded.secondLowestPrice) + } + + // MARK: - Identifiable Tests + + func testIdentifiable() { + let item = WatchlistItem( + id: 789, + name: "Test", + lowestPrice: 100, + lowestPriceQuantity: 1, + secondLowestPrice: 200, + lastUpdated: nil, + error: nil + ) + + XCTAssertEqual(item.id, 789) + } +} diff --git a/MacTorn/MacTornTests/ViewModels/AppStateTests.swift b/MacTorn/MacTornTests/ViewModels/AppStateTests.swift new file mode 100644 index 0000000..a7408eb --- /dev/null +++ b/MacTorn/MacTornTests/ViewModels/AppStateTests.swift @@ -0,0 +1,260 @@ +import XCTest +@testable import MacTorn + +@MainActor +final class AppStateTests: XCTestCase { + + var mockSession: MockNetworkSession! + var appState: AppState! + + override func setUp() async throws { + try await super.setUp() + mockSession = MockNetworkSession() + appState = AppState(session: mockSession) + // Clear any persisted data + UserDefaults.standard.removeObject(forKey: "apiKey") + UserDefaults.standard.removeObject(forKey: "watchlist") + UserDefaults.standard.removeObject(forKey: "notificationRules") + } + + override func tearDown() async throws { + appState.stopPolling() + appState = nil + mockSession = nil + try await super.tearDown() + } + + // MARK: - API Key Validation Tests + + func testFetchData_emptyAPIKey() async { + appState.apiKey = "" + + appState.fetchData() + + // Wait for async completion + try? await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(appState.errorMsg, "API Key required") + XCTAssertNil(appState.data) + } + + func testFetchData_invalidAPIKey_HTTP403() async throws { + appState.apiKey = "invalid_key" + mockSession.setHTTPError(statusCode: 403) + + appState.fetchData() + + // Wait for async completion + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertEqual(appState.errorMsg, "Invalid API Key") + XCTAssertNil(appState.data) + } + + func testFetchData_invalidAPIKey_HTTP404() async throws { + appState.apiKey = "invalid_key" + mockSession.setHTTPError(statusCode: 404) + + appState.fetchData() + + // Wait for async completion + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertEqual(appState.errorMsg, "Invalid API Key") + } + + // MARK: - Fetch Success Tests + + func testFetchData_success() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.validFullResponse) + + appState.fetchData() + + // Wait for async completion + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertNotNil(appState.data) + XCTAssertEqual(appState.data?.name, "TestPlayer") + XCTAssertEqual(appState.data?.playerId, 123456) + XCTAssertNil(appState.errorMsg) + XCTAssertNotNil(appState.lastUpdated) + } + + func testFetchData_parsesAllBars() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.validFullResponse) + + appState.fetchData() + + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertNotNil(appState.data?.bars) + XCTAssertEqual(appState.data?.energy?.current, 100) + XCTAssertEqual(appState.data?.nerve?.current, 50) + XCTAssertEqual(appState.data?.life?.current, 7500) + XCTAssertEqual(appState.data?.happy?.current, 5000) + } + + // MARK: - Torn API Error Tests + + func testFetchData_tornAPIError() async throws { + appState.apiKey = "valid_key" + try mockSession.setTornAPIError(code: 2, message: "Incorrect Key") + + appState.fetchData() + + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertEqual(appState.errorMsg, "API Error: Incorrect Key") + XCTAssertNil(appState.data) + } + + func testFetchData_tornAPIRateLimit() async throws { + appState.apiKey = "valid_key" + try mockSession.setTornAPIError(code: 5, message: "Too many requests") + + appState.fetchData() + + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertEqual(appState.errorMsg, "API Error: Too many requests") + } + + // MARK: - Network Error Tests + + func testFetchData_networkError() async throws { + appState.apiKey = "valid_key" + mockSession.setNetworkError(MockNetworkError.connectionFailed) + + appState.fetchData() + + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertNotNil(appState.errorMsg) + XCTAssertTrue(appState.errorMsg?.contains("Network error") ?? false) + } + + // MARK: - HTTP Error Tests + + func testFetchData_HTTP500() async throws { + appState.apiKey = "valid_key" + mockSession.setHTTPError(statusCode: 500) + + appState.fetchData() + + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertEqual(appState.errorMsg, "HTTP Error: 500") + } + + func testFetchData_HTTP502() async throws { + appState.apiKey = "valid_key" + mockSession.setHTTPError(statusCode: 502) + + appState.fetchData() + + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertEqual(appState.errorMsg, "HTTP Error: 502") + } + + // MARK: - Polling Tests + + func testStartPolling_fetchesData() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.validFullResponse) + + appState.startPolling() + + // Initial fetch should happen immediately + try await Task.sleep(nanoseconds: 1_000_000_000) + + XCTAssertTrue(mockSession.requestedURLs.count >= 1) + XCTAssertNotNil(appState.data) + } + + func testStopPolling_stopsTimer() { + appState.apiKey = "valid_key" + appState.startPolling() + + appState.stopPolling() + + // Timer should be cancelled + // No way to directly verify timer is nil, but we can verify no more requests happen + } + + // MARK: - Loading State Tests + + func testFetchData_setsLoadingState() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.validFullResponse) + + appState.fetchData() + + // Wait for completion - fetchData is async so loading transitions happen inside the Task + try await Task.sleep(nanoseconds: 1_000_000_000) + + // After completion, loading should be false + XCTAssertFalse(appState.isLoading) + // And we should have data + XCTAssertNotNil(appState.data) + } + + // MARK: - Notification Rules Tests + + func testLoadNotificationRules_defaults() { + // Clear existing rules + UserDefaults.standard.removeObject(forKey: "notificationRules") + + let newAppState = AppState(session: mockSession) + + XCTAssertFalse(newAppState.notificationRules.isEmpty) + // Should have default rules + } + + func testSaveNotificationRules() { + let rule = NotificationRule( + id: "test_rule", + barType: .energy, + threshold: 80, + enabled: true, + soundName: "default" + ) + appState.notificationRules = [rule] + appState.saveNotificationRules() + + // Reload + appState.loadNotificationRules() + + XCTAssertEqual(appState.notificationRules.count, 1) + XCTAssertEqual(appState.notificationRules.first?.id, "test_rule") + } + + func testUpdateRule() { + appState.notificationRules = NotificationRule.defaults + + var rule = appState.notificationRules.first! + rule.enabled = false + appState.updateRule(rule) + + XCTAssertFalse(appState.notificationRules.first!.enabled) + } + + // MARK: - Refresh Now Tests + + func testRefreshNow_triggersFetch() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.validFullResponse) + + // refreshNow calls fetchData which is async + appState.refreshNow() + + // Wait for async completion + try await Task.sleep(nanoseconds: 1_500_000_000) + + // Verify request was made + XCTAssertGreaterThanOrEqual(mockSession.requestedURLs.count, 1) + XCTAssertNotNil(appState.data) + } +} diff --git a/MacTorn/MacTornTests/ViewModels/AppStateWatchlistTests.swift b/MacTorn/MacTornTests/ViewModels/AppStateWatchlistTests.swift new file mode 100644 index 0000000..f69514d --- /dev/null +++ b/MacTorn/MacTornTests/ViewModels/AppStateWatchlistTests.swift @@ -0,0 +1,215 @@ +import XCTest +@testable import MacTorn + +@MainActor +final class AppStateWatchlistTests: XCTestCase { + + var mockSession: MockNetworkSession! + var appState: AppState! + + override func setUp() async throws { + try await super.setUp() + mockSession = MockNetworkSession() + appState = AppState(session: mockSession) + // Clear watchlist + UserDefaults.standard.removeObject(forKey: "watchlist") + appState.watchlistItems = [] + } + + override func tearDown() async throws { + appState.stopPolling() + appState = nil + mockSession = nil + UserDefaults.standard.removeObject(forKey: "watchlist") + try await super.tearDown() + } + + // MARK: - Add Item Tests + + func testAddToWatchlist_addsItem() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemSuccess) + + appState.addToWatchlist(itemId: 123, name: "Xanax") + + XCTAssertEqual(appState.watchlistItems.count, 1) + XCTAssertEqual(appState.watchlistItems.first?.id, 123) + XCTAssertEqual(appState.watchlistItems.first?.name, "Xanax") + } + + func testAddToWatchlist_preventsDuplicate() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemSuccess) + + appState.addToWatchlist(itemId: 123, name: "Xanax") + appState.addToWatchlist(itemId: 123, name: "Xanax") // Duplicate + + XCTAssertEqual(appState.watchlistItems.count, 1) // Should still be 1 + } + + func testAddToWatchlist_fetchesPriceImmediately() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemSuccess) + + appState.addToWatchlist(itemId: 123, name: "Xanax") + + // Wait for price fetch + try await Task.sleep(nanoseconds: 500_000_000) + + // Should have made a request to fetch price + XCTAssertTrue(mockSession.requestedURLs.contains { $0.absoluteString.contains("123") }) + } + + func testAddToWatchlist_multipleItems() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemSuccess) + + appState.addToWatchlist(itemId: 123, name: "Xanax") + appState.addToWatchlist(itemId: 456, name: "Donator Pack") + appState.addToWatchlist(itemId: 789, name: "Vicodin") + + XCTAssertEqual(appState.watchlistItems.count, 3) + } + + // MARK: - Remove Item Tests + + func testRemoveFromWatchlist_removesItem() { + appState.watchlistItems = [ + WatchlistItem(id: 123, name: "Xanax", lowestPrice: 1000, lowestPriceQuantity: 5, secondLowestPrice: 1100, lastUpdated: Date(), error: nil), + WatchlistItem(id: 456, name: "Donator Pack", lowestPrice: 9000000, lowestPriceQuantity: 3, secondLowestPrice: 9500000, lastUpdated: Date(), error: nil) + ] + + appState.removeFromWatchlist(123) + + XCTAssertEqual(appState.watchlistItems.count, 1) + XCTAssertNil(appState.watchlistItems.first(where: { $0.id == 123 })) + XCTAssertNotNil(appState.watchlistItems.first(where: { $0.id == 456 })) + } + + func testRemoveFromWatchlist_nonExistentItem() { + appState.watchlistItems = [ + WatchlistItem(id: 123, name: "Xanax", lowestPrice: 1000, lowestPriceQuantity: 5, secondLowestPrice: 1100, lastUpdated: Date(), error: nil) + ] + + appState.removeFromWatchlist(999) // Non-existent + + XCTAssertEqual(appState.watchlistItems.count, 1) // Should still have 1 item + } + + // MARK: - Persistence Tests + + func testSaveWatchlist_persists() { + appState.watchlistItems = [ + WatchlistItem(id: 123, name: "Xanax", lowestPrice: 1000, lowestPriceQuantity: 5, secondLowestPrice: 1100, lastUpdated: Date(), error: nil) + ] + + appState.saveWatchlist() + + // Create new instance and load + let newAppState = AppState(session: mockSession) + newAppState.loadWatchlist() + + XCTAssertEqual(newAppState.watchlistItems.count, 1) + XCTAssertEqual(newAppState.watchlistItems.first?.id, 123) + } + + func testLoadWatchlist_emptyWhenNothingSaved() { + UserDefaults.standard.removeObject(forKey: "watchlist") + + appState.loadWatchlist() + + XCTAssertTrue(appState.watchlistItems.isEmpty) + } + + // MARK: - Price Refresh Tests + + func testRefreshWatchlistPrices_refreshesAllItems() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemSuccess) + + appState.watchlistItems = [ + WatchlistItem(id: 123, name: "Xanax", lowestPrice: 0, lowestPriceQuantity: 0, secondLowestPrice: 0, lastUpdated: nil, error: nil), + WatchlistItem(id: 456, name: "Donator Pack", lowestPrice: 0, lowestPriceQuantity: 0, secondLowestPrice: 0, lastUpdated: nil, error: nil) + ] + + appState.refreshWatchlistPrices() + + // Wait for price fetches + try await Task.sleep(nanoseconds: 1_000_000_000) + + // Should have made requests for both items + let requestedURLStrings = mockSession.requestedURLs.map { $0.absoluteString } + XCTAssertTrue(requestedURLStrings.contains { $0.contains("123") }) + XCTAssertTrue(requestedURLStrings.contains { $0.contains("456") }) + } + + // MARK: - Price Update Tests + + func testPriceFetch_updatesPrices() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemSuccess) + + appState.addToWatchlist(itemId: 123, name: "Test Item") + + // Wait for price fetch + try await Task.sleep(nanoseconds: 1_000_000_000) + + let item = appState.watchlistItems.first + XCTAssertNotNil(item) + // Prices should be updated from fixtures (950 is lowest from bazaar) + XCTAssertGreaterThan(item?.lowestPrice ?? 0, 0) + } + + func testPriceFetch_noListings() async throws { + appState.apiKey = "valid_key" + try mockSession.setSuccessResponse(json: TornAPIFixtures.marketItemNoListings) + + appState.addToWatchlist(itemId: 123, name: "Rare Item") + + // Wait for price fetch + try await Task.sleep(nanoseconds: 1_000_000_000) + + let item = appState.watchlistItems.first + XCTAssertEqual(item?.error, "No listings") + } + + func testPriceFetch_networkError() async throws { + appState.apiKey = "valid_key" + mockSession.setNetworkError(MockNetworkError.connectionFailed) + + appState.addToWatchlist(itemId: 123, name: "Test Item") + + // Wait for price fetch + try await Task.sleep(nanoseconds: 1_000_000_000) + + let item = appState.watchlistItems.first + XCTAssertEqual(item?.error, "Network Error") + } + + func testPriceFetch_httpError() async throws { + appState.apiKey = "valid_key" + mockSession.setHTTPError(statusCode: 500) + + appState.addToWatchlist(itemId: 123, name: "Test Item") + + // Wait for price fetch + try await Task.sleep(nanoseconds: 1_000_000_000) + + let item = appState.watchlistItems.first + XCTAssertEqual(item?.error, "HTTP 500") + } + + // MARK: - Empty API Key Tests + + func testPriceFetch_emptyAPIKey() async throws { + appState.apiKey = "" + + appState.addToWatchlist(itemId: 123, name: "Test Item") + + // Wait + try await Task.sleep(nanoseconds: 500_000_000) + + // No requests should be made + XCTAssertTrue(mockSession.requestedURLs.isEmpty) + } +} diff --git a/MacTorn/MacTornUITests/MacTornUITests.swift b/MacTorn/MacTornUITests/MacTornUITests.swift new file mode 100644 index 0000000..8734e2f --- /dev/null +++ b/MacTorn/MacTornUITests/MacTornUITests.swift @@ -0,0 +1,150 @@ +import XCTest + +final class MacTornUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUpWithError() throws { + continueAfterFailure = false + + app = XCUIApplication() + // Reset state for each test + app.launchArguments = ["--uitesting"] + app.launch() + } + + override func tearDownWithError() throws { + app = nil + } + + // MARK: - Settings View Tests + + func testSettingsView_appearsWhenNoAPIKey() throws { + // When no API key is set, settings should appear + // Note: This test assumes a clean state or --uitesting launch argument clears the key + + // Look for settings-related UI elements + let settingsElements = app.windows.firstMatch + + // The app should show some form of settings or API key input + // Adjust selectors based on actual UI implementation + XCTAssertTrue(settingsElements.exists) + } + + // MARK: - Tab Navigation Tests + + func testTabNavigation_allTabsExist() throws { + // Skip if settings view is blocking + // This test verifies the main tab structure + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + + // Look for tab bar or navigation elements + // Adjust based on actual UI implementation + } + + func testTabNavigation_switchBetweenTabs() throws { + let window = app.windows.firstMatch + + // Tab navigation test + // Adjust selectors based on actual tab implementation + // e.g., app.buttons["Status"].tap() + + XCTAssertTrue(window.exists) + } + + // MARK: - Refresh Button Tests + + func testRefreshButton_exists() throws { + let window = app.windows.firstMatch + + // Look for refresh button + // Adjust selector based on actual implementation + // let refreshButton = window.buttons["Refresh"] + + XCTAssertTrue(window.exists) + } + + func testRefreshButton_triggersRefresh() throws { + // This test would verify that tapping refresh triggers a data fetch + // Would need to mock network or observe state changes + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + } + + // MARK: - Watchlist UI Tests + + func testWatchlist_addItem() throws { + // Navigate to watchlist tab + // Add an item + // Verify it appears in the list + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + } + + func testWatchlist_removeItem() throws { + // Navigate to watchlist tab + // Remove an item + // Verify it's no longer in the list + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + } + + // MARK: - Error State Tests + + func testErrorState_displaysErrorMessage() throws { + // Trigger an error state + // Verify error message is displayed + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + } + + // MARK: - Loading State Tests + + func testLoadingState_showsLoadingIndicator() throws { + // During data fetch, loading indicator should be visible + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + } + + // MARK: - Status View Tests + + func testStatusView_displaysBars() throws { + // Verify energy, nerve, life, happy bars are displayed + + let window = app.windows.firstMatch + XCTAssertTrue(window.exists) + } + + // MARK: - Performance Tests + + func testLaunchPerformance() throws { + if #available(macOS 10.15, *) { + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} + +// MARK: - UI Test Helpers + +extension XCUIElement { + /// Wait for element to exist with timeout + func waitForExistence(timeout: TimeInterval = 5) -> Bool { + return self.waitForExistence(timeout: timeout) + } + + /// Tap if element exists + func tapIfExists() { + if self.exists { + self.tap() + } + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2f94e3 --- /dev/null +++ b/Makefile @@ -0,0 +1,119 @@ +# MacTorn Makefile +# Run tests and build commands for local development + +.PHONY: test test-unit test-ui build clean coverage help + +# Default target +help: + @echo "MacTorn Build Commands:" + @echo "" + @echo " make test - Run all unit tests" + @echo " make test-ui - Run UI tests" + @echo " make build - Build the app in Debug mode" + @echo " make release - Build the app in Release mode" + @echo " make clean - Clean build artifacts" + @echo " make coverage - Run tests with code coverage" + @echo "" + +# Run unit tests +test: + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -only-testing:MacTornTests \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + +# Run unit tests (alias) +test-unit: test + +# Run UI tests +test-ui: + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -only-testing:MacTornUITests \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + +# Run all tests (unit + UI) +test-all: + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + +# Build Debug +build: + xcodebuild build \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + +# Build Release +release: + xcodebuild build \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -configuration Release \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + +# Clean build artifacts +clean: + xcodebuild clean \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn + rm -rf build/ + rm -rf DerivedData/ + rm -rf TestResults/ + +# Run tests with code coverage +coverage: + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -enableCodeCoverage YES \ + -resultBundlePath TestResults \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO + @echo "" + @echo "Coverage report generated in TestResults/" + @echo "Open TestResults/action.xccovreport to view in Xcode" + +# Quick test - faster iteration +quick-test: + xcodebuild test \ + -project MacTorn/MacTorn.xcodeproj \ + -scheme MacTorn \ + -destination 'platform=macOS' \ + -only-testing:MacTornTests \ + -parallel-testing-enabled YES \ + CODE_SIGN_IDENTITY="-" \ + CODE_SIGNING_REQUIRED=NO \ + 2>&1 | xcpretty --color + +# Watch for changes and run tests (requires fswatch) +watch: + @echo "Watching for changes... (requires fswatch)" + fswatch -o MacTorn/MacTorn MacTorn/MacTornTests | xargs -n1 -I{} make quick-test + +# Open project in Xcode +open: + open MacTorn/MacTorn.xcodeproj + +# Show test summary +test-summary: + @echo "Test Summary:" + @echo "=============" + @find . -name "*.swift" -path "*/MacTornTests/*" | xargs grep -l "func test" | wc -l | xargs echo "Test files:" + @find . -name "*.swift" -path "*/MacTornTests/*" | xargs grep "func test" | wc -l | xargs echo "Test cases:"