feat: Implement comprehensive unit and UI tests for models and view models, including a new network session, mocks, fixtures, and a CI workflow.

This commit is contained in:
Paweł Orzech 2026-01-17 23:19:29 +00:00
parent ca7d0dfe84
commit 1ce929bffa
No known key found for this signature in database
18 changed files with 2693 additions and 11 deletions

103
.github/workflows/tests.yml vendored Normal file
View file

@ -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"

View file

@ -27,8 +27,41 @@
AAA00018 /* FactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10019 /* FactionView.swift */; }; AAA00018 /* FactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10019 /* FactionView.swift */; };
AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; }; AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; };
AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.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 */ /* 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 */ /* Begin PBXFileReference section */
AAA10001 /* MacTornApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornApp.swift; sourceTree = "<group>"; }; AAA10001 /* MacTornApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornApp.swift; sourceTree = "<group>"; };
AAA10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; AAA10002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -51,7 +84,25 @@
AAA10019 /* FactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactionView.swift; sourceTree = "<group>"; }; AAA10019 /* FactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactionView.swift; sourceTree = "<group>"; };
AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = "<group>"; }; AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = "<group>"; };
AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = "<group>"; }; AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = "<group>"; };
AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = "<group>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
BBB10002 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
BBB10003 /* TornAPIFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TornAPIFixtures.swift; sourceTree = "<group>"; };
BBB10004 /* BarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarTests.swift; sourceTree = "<group>"; };
BBB10005 /* TravelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelTests.swift; sourceTree = "<group>"; };
BBB10006 /* StatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTests.swift; sourceTree = "<group>"; };
BBB10007 /* ChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainTests.swift; sourceTree = "<group>"; };
BBB10008 /* TornResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TornResponseTests.swift; sourceTree = "<group>"; };
BBB10009 /* WatchlistItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistItemTests.swift; sourceTree = "<group>"; };
BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = "<group>"; };
BBB10011 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
BBB10012 /* AppStateWatchlistTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWatchlistTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
CCC10000 /* MacTornUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -62,6 +113,20 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
BBB20000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
CCC20000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -69,6 +134,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AAA30001 /* MacTorn */, AAA30001 /* MacTorn */,
BBB30000 /* MacTornTests */,
CCC30000 /* MacTornUITests */,
AAA30002 /* Products */, AAA30002 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -83,6 +150,7 @@
AAA30004 /* ViewModels */, AAA30004 /* ViewModels */,
AAA30005 /* Views */, AAA30005 /* Views */,
AAA30007 /* Utilities */, AAA30007 /* Utilities */,
AAA30008 /* Networking */,
); );
path = MacTorn; path = MacTorn;
sourceTree = "<group>"; sourceTree = "<group>";
@ -91,6 +159,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AAA10000 /* MacTorn.app */, AAA10000 /* MacTorn.app */,
BBB10000 /* MacTornTests.xctest */,
CCC10000 /* MacTornUITests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -149,6 +219,75 @@
path = Utilities; path = Utilities;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AAA30008 /* Networking */ = {
isa = PBXGroup;
children = (
AAA10022 /* NetworkSession.swift */,
);
path = Networking;
sourceTree = "<group>";
};
/* Unit Tests Groups */
BBB30000 /* MacTornTests */ = {
isa = PBXGroup;
children = (
BBB30001 /* Mocks */,
BBB30002 /* Fixtures */,
BBB30003 /* Models */,
BBB30004 /* ViewModels */,
);
path = MacTornTests;
sourceTree = "<group>";
};
BBB30001 /* Mocks */ = {
isa = PBXGroup;
children = (
BBB10001 /* MockNetworkSession.swift */,
BBB10002 /* TestHelpers.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
BBB30002 /* Fixtures */ = {
isa = PBXGroup;
children = (
BBB10003 /* TornAPIFixtures.swift */,
);
path = Fixtures;
sourceTree = "<group>";
};
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 = "<group>";
};
BBB30004 /* ViewModels */ = {
isa = PBXGroup;
children = (
BBB10011 /* AppStateTests.swift */,
BBB10012 /* AppStateWatchlistTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
/* UI Tests Group */
CCC30000 /* MacTornUITests */ = {
isa = PBXGroup;
children = (
CCC10001 /* MacTornUITests.swift */,
);
path = MacTornUITests;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -169,6 +308,42 @@
productReference = AAA10000 /* MacTorn.app */; productReference = AAA10000 /* MacTorn.app */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -182,6 +357,14 @@
AAA40000 = { AAA40000 = {
CreatedOnToolsVersion = 15.0; CreatedOnToolsVersion = 15.0;
}; };
BBB40000 = {
CreatedOnToolsVersion = 15.0;
TestTargetID = AAA40000;
};
CCC40000 = {
CreatedOnToolsVersion = 15.0;
TestTargetID = AAA40000;
};
}; };
}; };
buildConfigurationList = AAA60001 /* Build configuration list for PBXProject "MacTorn" */; buildConfigurationList = AAA60001 /* Build configuration list for PBXProject "MacTorn" */;
@ -198,6 +381,8 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
AAA40000 /* MacTorn */, AAA40000 /* MacTorn */,
BBB40000 /* MacTornTests */,
CCC40000 /* MacTornUITests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -211,6 +396,20 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
BBB50001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
CCC50001 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -237,11 +436,52 @@
AAA00018 /* FactionView.swift in Sources */, AAA00018 /* FactionView.swift in Sources */,
AAA00019 /* WatchlistView.swift in Sources */, AAA00019 /* WatchlistView.swift in Sources */,
AAA00020 /* PropertiesView.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; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* 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 */ /* Begin XCBuildConfiguration section */
AAA70000 /* Debug */ = { AAA70000 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@ -413,6 +653,78 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -434,6 +746,24 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; 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 */ /* End XCConfigurationList section */
}; };
rootObject = AAA00000 /* Project object */; rootObject = AAA00000 /* Project object */;

View file

@ -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 {}

View file

@ -10,14 +10,14 @@ class AppState: ObservableObject {
// MARK: - Persisted // MARK: - Persisted
@AppStorage("apiKey") var apiKey: String = "" @AppStorage("apiKey") var apiKey: String = ""
@AppStorage("refreshInterval") var refreshInterval: Int = 30 @AppStorage("refreshInterval") var refreshInterval: Int = 30
// MARK: - Published State // MARK: - Published State
@Published var data: TornResponse? @Published var data: TornResponse?
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@Published var errorMsg: String? @Published var errorMsg: String?
@Published var isLoading: Bool = false @Published var isLoading: Bool = false
@Published var notificationRules: [NotificationRule] = [] @Published var notificationRules: [NotificationRule] = []
// MARK: - New Data Sources // MARK: - New Data Sources
@Published var moneyData: MoneyData? @Published var moneyData: MoneyData?
@Published var battleStats: BattleStats? @Published var battleStats: BattleStats?
@ -25,26 +25,30 @@ class AppState: ObservableObject {
@Published var factionData: FactionData? @Published var factionData: FactionData?
@Published var propertiesData: [PropertyInfo]? @Published var propertiesData: [PropertyInfo]?
@Published var watchlistItems: [WatchlistItem] = [] @Published var watchlistItems: [WatchlistItem] = []
// MARK: - Update State // MARK: - Update State
@Published var updateAvailable: GitHubRelease? @Published var updateAvailable: GitHubRelease?
// MARK: - Managers // MARK: - Managers
let launchAtLogin = LaunchAtLoginManager() let launchAtLogin = LaunchAtLoginManager()
let shortcutsManager = ShortcutsManager() let shortcutsManager = ShortcutsManager()
let updateManager = UpdateManager.shared let updateManager = UpdateManager.shared
// MARK: - Networking (Dependency Injection for Testing)
private let session: NetworkSession
// MARK: - State Comparison // MARK: - State Comparison
private var previousBars: Bars? private var previousBars: Bars?
private var previousCooldowns: Cooldowns? private var previousCooldowns: Cooldowns?
private var previousTravel: Travel? private var previousTravel: Travel?
private var previousChain: Chain? private var previousChain: Chain?
private var previousStatus: Status? private var previousStatus: Status?
// MARK: - Timer // MARK: - Timer
private var timerCancellable: AnyCancellable? private var timerCancellable: AnyCancellable?
init() { init(session: NetworkSession = URLSession.shared) {
self.session = session
loadNotificationRules() loadNotificationRules()
loadWatchlist() loadWatchlist()
// Polling and permissions moved to onAppear in UI // Polling and permissions moved to onAppear in UI
@ -126,7 +130,7 @@ class AppState: ObservableObject {
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData 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 { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
logger.error("Item \(itemId) HTTP Error: \(httpResponse.statusCode)") logger.error("Item \(itemId) HTTP Error: \(httpResponse.statusCode)")
@ -268,7 +272,7 @@ class AppState: ObservableObject {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData 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 { guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse throw APIError.invalidResponse
@ -452,7 +456,7 @@ class AppState: ObservableObject {
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData 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 // Check for Torn API error
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],

View file

@ -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 <a href='...'>Someone</a>",
"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)!
}
}

View file

@ -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
}

View file

@ -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<T: Decodable>(_ 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<T: Decodable>(_ 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)
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 <a href='profile.php?XID=12345'>SomePlayer</a>.",
"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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}

119
Makefile Normal file
View file

@ -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:"