test(update-checker): rewrite tests to exercise real production code

REPLACED: three worthless tests that only tested test code, not production:
- statusMessageUpToDateFormat built the expected string locally and matched it
- statusMessageUpdateReadyFormat same self-referential test
- statusChannelsAreIndependent was literally #expect(true, ...)

ADDED: four real tests that drive production code:
- dubaiTimeCheckTimeFormat: assert DubaiTime.checkTime() formats as HH:MM Dubai
- manualCheckUpToDateIncludesTimestamp: inject stub appcast via testAppcastJSON seam,
  drive UpdateChecker.checkNow(manual: true), verify statusMessage matches exact format
- automaticCheckUpToDateLeavesMessageNil: verify automatic check (manual: false) leaves
  statusMessage nil when up-to-date
- statusChannelsAreIndependent: construct real AppModel via AppDelegate.makeLaunchModel(),
  assert setStatus() does NOT affect updateStatusMessage, setUpdateStatus() does NOT
  affect statusLine, and vice versa. PROVES the defect is caught: test fails with 3 issues
  if updateStatusMessage is reverted to an alias of statusLine.

ADDED: testAppcastJSON seam to UpdateChecker for test injection of appcast data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ZiTXPbPCSjzPVsfoweAbp
This commit is contained in:
Claude Fable 5
2026-09-05 11:13:55 +04:00
parent 905a019823
commit 942e848dde
2 changed files with 97 additions and 33 deletions
+8 -1
View File
@@ -36,6 +36,8 @@ final class UpdateChecker {
/// this value (including nil) is returned instead of checking the file system.
var snapshotPreviousVersionOverride: String?
var snapshotUsesPreviousVersionOverride: Bool = false
/// Test seam: override appcast JSON. When set, returns this instead of fetching from URL.
var testAppcastJSON: String?
init() {
let config = URLSessionConfiguration.ephemeral
@@ -359,7 +361,12 @@ final class UpdateChecker {
}
private func fetchAppcast() async throws -> Appcast {
let data = try await fetchData(from: Self.resolvedAppcastURL())
let data: Data
if let testJSON = testAppcastJSON {
data = testJSON.data(using: .utf8) ?? Data()
} else {
data = try await fetchData(from: Self.resolvedAppcastURL())
}
return try JSONDecoder().decode(Appcast.self, from: data)
}
+89 -32
View File
@@ -5,11 +5,10 @@ import Testing
@Test("DubaiTime.checkTime formats as HH:MM Dubai")
func dubaiTimeCheckTimeFormat() {
let testDate = Date(timeIntervalSince1970: 1725458520) // 2024-09-04 10:42:00 UTC
let result = DubaiTime.checkTime(testDate)
let now = Date()
let result = DubaiTime.checkTime(now)
// The format should be HH:MM Dubai (24-hour time in Dubai timezone)
// Dubai is UTC+4, so a UTC time needs conversion
// Dubai timezone format: HH:MM Dubai
let pattern = "^[0-9]{2}:[0-9]{2} Dubai$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(result.startIndex..<result.endIndex, in: result)
@@ -17,45 +16,103 @@ func dubaiTimeCheckTimeFormat() {
#expect(!matches.isEmpty, "checkTime should format as HH:MM Dubai, got: \(result)")
}
@Test("Status message format: 'Redline X.Y.Z is up to date, checked HH:MM Dubai'")
@Test("Status message: up-to-date manual check includes Dubai timestamp")
@MainActor
func statusMessageUpToDateFormat() {
let currentVersion = UpdateChecker.currentVersion()
let testTime = DubaiTime.checkTime(Date())
let message = "Redline \(currentVersion) is up to date, checked \(testTime)"
func manualCheckUpToDateIncludesTimestamp() async {
let checker = UpdateChecker()
// Verify the format matches the expected pattern
// Inject a stub appcast showing the current version (no update available)
let currentVersion = UpdateChecker.currentVersion()
let stubAppcast = """
{
"version": "\(currentVersion)",
"zipURL": "https://example.com/dummy.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
"""
checker.testAppcastJSON = stubAppcast
// Capture the status message
var capturedStatus: String?
checker.onChecked = {
capturedStatus = checker.statusMessage
}
// Run the manual check
await checker.checkNow(manual: true)
// Verify the message matches the expected format and includes a timestamp
guard let status = capturedStatus else {
#expect(false, "statusMessage should not be nil for manual check finding no update")
return
}
// Message should be "Redline X.Y.Z is up to date, checked HH:MM Dubai"
let pattern = "^Redline [0-9]+\\.[0-9]+\\.[0-9]+ is up to date, checked [0-9]{2}:[0-9]{2} Dubai$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(message.startIndex..<message.endIndex, in: message)
let matches = regex?.matches(in: message, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Message should match format, got: \(message)")
let range = NSRange(status.startIndex..<status.endIndex, in: status)
let matches = regex?.matches(in: status, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Manual check up-to-date message should match format, got: \(status)")
}
@Test("Status message format: 'Update to X.Y.Z is ready'")
func statusMessageUpdateReadyFormat() {
let testVersion = "9.9.9"
let message = "Update to \(testVersion) is ready"
@Test("Status message: automatic check doesn't set message when up-to-date")
@MainActor
func automaticCheckUpToDateLeavesMessageNil() async {
let checker = UpdateChecker()
// Verify the format matches the expected pattern
let pattern = "^Update to [0-9]+\\.[0-9]+\\.[0-9]+ is ready$"
let regex = try? NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(message.startIndex..<message.endIndex, in: message)
let matches = regex?.matches(in: message, options: [], range: range) ?? []
#expect(!matches.isEmpty, "Message should match format, got: \(message)")
// Inject a stub appcast showing the current version (no update available)
let currentVersion = UpdateChecker.currentVersion()
let stubAppcast = """
{
"version": "\(currentVersion)",
"zipURL": "https://example.com/dummy.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
"""
checker.testAppcastJSON = stubAppcast
// Capture the status message
var capturedStatus: String?
checker.onChecked = {
capturedStatus = checker.statusMessage
}
// Run an AUTOMATIC check (manual: false)
await checker.checkNow(manual: false)
// For automatic checks finding no update, statusMessage should be nil
#expect(capturedStatus == nil,
"Automatic check finding no update should leave statusMessage nil, got: \(capturedStatus ?? "(nil)")")
}
@Test("Update status and general status are independent channels")
@MainActor
func statusChannelsAreIndependent() {
// Test the channel independence without creating a full model.
// setStatus affects statusLine, setUpdateStatus affects updateStatus.
// They should be separate properties that don't interfere.
func statusChannelsAreIndependent() async throws {
let fm = FileManager.default
let appSupportRoot = fm.temporaryDirectory
.appendingPathComponent("update-checker-channel-test-\(UUID().uuidString)", isDirectory: true)
defer { try? fm.removeItem(at: appSupportRoot) }
// Hypothetical test: if we had a model, setting one shouldn't affect the other.
// For now, we verify that the API exists and can be called independently.
// The full integration test happens in the panel snapshot.
// Create a real AppModel using the standard launch pattern
let model = AppDelegate.makeLaunchModel(appSupportRoot: appSupportRoot)
defer { model.hotkeys.unregisterAll() }
// Verify the property names and access patterns are correct
#expect(true, "Status channels are independent by design: statusLine and updateStatus")
// Test 1: Setting statusLine should NOT affect updateStatusMessage
model.setStatus("General status: captured 3")
#expect(model.statusLine == "General status: captured 3", "statusLine should be set")
#expect(model.updateStatusMessage == nil, "updateStatusMessage should remain nil")
// Test 2: Setting updateStatus should NOT affect statusLine
model.setUpdateStatus("Update to 9.9.9 is ready")
#expect(model.statusLine == "General status: captured 3", "statusLine should remain unchanged")
#expect(model.updateStatusMessage == "Update to 9.9.9 is ready", "updateStatusMessage should be set")
// Test 3: Clearing statusLine leaves updateStatus intact
model.setStatus(nil)
#expect(model.statusLine == nil, "statusLine should be cleared")
#expect(model.updateStatusMessage == "Update to 9.9.9 is ready", "updateStatusMessage should persist")
// Test 4: Clearing updateStatus leaves other state unaffected
model.setUpdateStatus(nil)
#expect(model.updateStatusMessage == nil, "updateStatusMessage should be cleared")
}