mirror of
https://github.com/VGEAREN/Stockbar.git
synced 2026-10-07 14:08:04 +08:00
PRIOR BEHAVIOR (data-loss bug): AppStateTests.setUp() constructed AppState() directly, which used the real ~/Library/Application Support/Stockbar/ for stocks.json. Each test method that did 'sut.stocks = [s1, s2]' triggered didSet → saveStocks → silently overwrote the user's actual portfolio file with 2 test entries. backupIfNeeded then rolled all 10 backup snapshots forward, eventually erasing every historical copy of real data. This destroyed a user's 29-stock portfolio when xcodebuild test was run during Korean stock feature development (2026-05-14 10:08). FIX: - AppState gains a static appSupportDirOverride: URL? hook - AppStateTests.setUp() creates a unique tmpDir, sets the override, tearDown resets and removes the directory - Verified: after running all 7 AppStateTests, the real stocks.json is byte-identical to before the run. This must land before any further test run touches AppState.
82 lines
3.1 KiB
Swift
82 lines
3.1 KiB
Swift
import XCTest
|
|
@testable import Stockbar
|
|
|
|
@MainActor
|
|
final class AppStateTests: XCTestCase {
|
|
var sut: AppState!
|
|
var tmpDir: URL!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
// CRITICAL: AppState reads/writes ~/Library/Application Support/Stockbar/
|
|
// by default. Without isolation, every test mutation would overwrite the
|
|
// user's real stocks.json. Always redirect to a per-test temp dir.
|
|
tmpDir = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
.appendingPathComponent("StockbarTests-\(UUID().uuidString)")
|
|
try? FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
|
|
AppState.appSupportDirOverride = tmpDir
|
|
|
|
sut = AppState()
|
|
sut.stocks = []
|
|
sut.quotes = [:]
|
|
sut.statusBarStockId = ""
|
|
}
|
|
|
|
override func tearDown() {
|
|
sut = nil
|
|
AppState.appSupportDirOverride = nil
|
|
if let d = tmpDir { try? FileManager.default.removeItem(at: d) }
|
|
tmpDir = nil
|
|
super.tearDown()
|
|
}
|
|
|
|
func test_statusBarStock_returnsNilWhenEmpty() {
|
|
sut.stocks = []
|
|
XCTAssertNil(sut.statusBarStock)
|
|
}
|
|
|
|
func test_statusBarStock_fallbackToFirstWhenIdNotFound() {
|
|
let s = Stock(id: "sh600000", name: "股票1", market: .aStock, costPrice: nil, holdingShares: nil)
|
|
sut.stocks = [s]
|
|
sut.statusBarStockId = "nonexistent"
|
|
XCTAssertEqual(sut.statusBarStock?.id, "sh600000")
|
|
}
|
|
|
|
func test_statusBarStock_matchesById() {
|
|
let s1 = Stock(id: "sh600000", name: "股票1", market: .aStock, costPrice: nil, holdingShares: nil)
|
|
let s2 = Stock(id: "sh600001", name: "股票2", market: .aStock, costPrice: nil, holdingShares: nil)
|
|
sut.stocks = [s1, s2]
|
|
sut.statusBarStockId = "sh600001"
|
|
XCTAssertEqual(sut.statusBarStock?.id, "sh600001")
|
|
}
|
|
|
|
func test_hasPnLData_falseWithNoHoldings() {
|
|
sut.stocks = [Stock(id: "sh600000", name: "股票1", market: .aStock, costPrice: nil, holdingShares: nil)]
|
|
XCTAssertFalse(sut.hasPnLData)
|
|
}
|
|
|
|
func test_hasPnLData_trueWithHolding() {
|
|
sut.stocks = [Stock(id: "sh600000", name: "股票1", market: .aStock, costPrice: 10.0, holdingShares: 100)]
|
|
XCTAssertTrue(sut.hasPnLData)
|
|
}
|
|
|
|
func test_totalPnL_sumsPnLsFromQuotes() {
|
|
sut.stocks = [
|
|
Stock(id: "sh600000", name: "股票1", market: .aStock, costPrice: 10.0, holdingShares: 100),
|
|
Stock(id: "sh600001", name: "股票2", market: .aStock, costPrice: 5.0, holdingShares: 200)
|
|
]
|
|
sut.quotes = [
|
|
"sh600000": Quote(code: "sh600000", price: 12.0, change: 2.0, changePercent: 20.0, updateTime: ""),
|
|
"sh600001": Quote(code: "sh600001", price: 4.0, change: -1.0, changePercent: -20.0, updateTime: "")
|
|
]
|
|
// (12-10)*100 + (4-5)*200 = 200 - 200 = 0
|
|
XCTAssertEqual(sut.totalPnL, 0.0, accuracy: 0.001)
|
|
}
|
|
|
|
func test_totalPnL_skipsStocksWithoutQuotes() {
|
|
sut.stocks = [Stock(id: "sh600000", name: "股票1", market: .aStock, costPrice: 10.0, holdingShares: 100)]
|
|
sut.quotes = [:]
|
|
XCTAssertEqual(sut.totalPnL, 0.0, accuracy: 0.001)
|
|
}
|
|
}
|