Compare commits
5
Commits
e71c2e7c91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3af20d04 | ||
|
|
ae7fb8ee87 | ||
|
|
821e113f34 | ||
|
|
942e848dde | ||
|
|
905a019823 |
+2
-2
@@ -13,9 +13,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.1</string>
|
<string>0.3.2</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>4</string>
|
<string>5</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>14.0</string>
|
<string>14.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public final class AppModel {
|
|||||||
public private(set) var allReturns: [ReturnedDocument] = []
|
public private(set) var allReturns: [ReturnedDocument] = []
|
||||||
public private(set) var commentedReturns: [ReturnedDocument] = []
|
public private(set) var commentedReturns: [ReturnedDocument] = []
|
||||||
public private(set) var statusLine: String?
|
public private(set) var statusLine: String?
|
||||||
|
public private(set) var updateStatus: String?
|
||||||
public private(set) var isCapturing: Bool = false
|
public private(set) var isCapturing: Bool = false
|
||||||
public private(set) var isSending: Bool = false
|
public private(set) var isSending: Bool = false
|
||||||
public private(set) var outboxDisplayName: String
|
public private(set) var outboxDisplayName: String
|
||||||
@@ -106,9 +107,7 @@ public final class AppModel {
|
|||||||
self.updateChecker.onChecked = { [weak self] in
|
self.updateChecker.onChecked = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.updateAvailable = self.updateChecker.availableUpdate
|
self.updateAvailable = self.updateChecker.availableUpdate
|
||||||
if let message = self.updateChecker.statusMessage {
|
self.setUpdateStatus(self.updateChecker.statusMessage)
|
||||||
self.setStatus(message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.updateChecker.onCheckingChanged = { [weak self] checking in
|
self.updateChecker.onCheckingChanged = { [weak self] checking in
|
||||||
self?.isCheckingForUpdates = checking
|
self?.isCheckingForUpdates = checking
|
||||||
@@ -119,13 +118,13 @@ public final class AppModel {
|
|||||||
public var appVersion: String { UpdateChecker.currentVersion() }
|
public var appVersion: String { UpdateChecker.currentVersion() }
|
||||||
/// Version recorded in the app-managed rollback copy, when one exists.
|
/// Version recorded in the app-managed rollback copy, when one exists.
|
||||||
public var previousVersion: String? { updateChecker.previousVersion() }
|
public var previousVersion: String? { updateChecker.previousVersion() }
|
||||||
/// Most recent status text — shared with the general status line by design
|
/// Update-related status text (checked time, staged update, errors). Displayed only in the footer.
|
||||||
/// (Redline has one status channel, not a separate update-only one).
|
public var updateStatusMessage: String? { updateStatus }
|
||||||
public var updateStatusMessage: String? { statusLine }
|
|
||||||
|
|
||||||
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
|
// MARK: Seam mutators — the only way a WP-4b/4c extension changes state.
|
||||||
|
|
||||||
func setStatus(_ text: String?) { statusLine = text }
|
func setStatus(_ text: String?) { statusLine = text }
|
||||||
|
func setUpdateStatus(_ text: String?) { updateStatus = text }
|
||||||
func setSending(_ value: Bool) { isSending = value }
|
func setSending(_ value: Bool) { isSending = value }
|
||||||
func setCapturing(_ value: Bool) { isCapturing = value }
|
func setCapturing(_ value: Bool) { isCapturing = value }
|
||||||
func replaceSession(_ new: CaptureSession) { session = new }
|
func replaceSession(_ new: CaptureSession) { session = new }
|
||||||
|
|||||||
@@ -400,9 +400,9 @@ extension AppModel {
|
|||||||
self[keyPath: writable] = checking
|
self[keyPath: writable] = checking
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot-only: set updateStatusMessage (statusLine alias) for panel display.
|
/// Snapshot-only: set updateStatusMessage for panel display.
|
||||||
func snapshotSetUpdateStatusMessage(_ message: String?) {
|
func snapshotSetUpdateStatusMessage(_ message: String?) {
|
||||||
setStatus(message)
|
setUpdateStatus(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot-only: set updateAvailable without triggering a real download.
|
/// Snapshot-only: set updateAvailable without triggering a real download.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ final class UpdateChecker {
|
|||||||
/// this value (including nil) is returned instead of checking the file system.
|
/// this value (including nil) is returned instead of checking the file system.
|
||||||
var snapshotPreviousVersionOverride: String?
|
var snapshotPreviousVersionOverride: String?
|
||||||
var snapshotUsesPreviousVersionOverride: Bool = false
|
var snapshotUsesPreviousVersionOverride: Bool = false
|
||||||
|
/// Test seam: override appcast JSON. When set, returns this instead of fetching from URL.
|
||||||
|
var testAppcastJSON: String?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let config = URLSessionConfiguration.ephemeral
|
let config = URLSessionConfiguration.ephemeral
|
||||||
@@ -359,7 +361,12 @@ final class UpdateChecker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchAppcast() async throws -> Appcast {
|
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)
|
return try JSONDecoder().decode(Appcast.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import Testing
|
|||||||
|
|
||||||
@Test("DubaiTime.checkTime formats as HH:MM Dubai")
|
@Test("DubaiTime.checkTime formats as HH:MM Dubai")
|
||||||
func dubaiTimeCheckTimeFormat() {
|
func dubaiTimeCheckTimeFormat() {
|
||||||
let testDate = Date(timeIntervalSince1970: 1725458520) // 2024-09-04 10:42:00 UTC
|
let now = Date()
|
||||||
let result = DubaiTime.checkTime(testDate)
|
let result = DubaiTime.checkTime(now)
|
||||||
|
|
||||||
// The format should be HH:MM Dubai (24-hour time in Dubai timezone)
|
// Dubai timezone format: HH:MM Dubai
|
||||||
// Dubai is UTC+4, so a UTC time needs conversion
|
|
||||||
let pattern = "^[0-9]{2}:[0-9]{2} Dubai$"
|
let pattern = "^[0-9]{2}:[0-9]{2} Dubai$"
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
let range = NSRange(result.startIndex..<result.endIndex, in: result)
|
let range = NSRange(result.startIndex..<result.endIndex, in: result)
|
||||||
@@ -17,30 +16,103 @@ func dubaiTimeCheckTimeFormat() {
|
|||||||
#expect(!matches.isEmpty, "checkTime should format as HH:MM Dubai, got: \(result)")
|
#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
|
@MainActor
|
||||||
func statusMessageUpToDateFormat() {
|
func manualCheckUpToDateIncludesTimestamp() async {
|
||||||
let currentVersion = UpdateChecker.currentVersion()
|
let checker = UpdateChecker()
|
||||||
let testTime = DubaiTime.checkTime(Date())
|
|
||||||
let message = "Redline \(currentVersion) is up to date, checked \(testTime)"
|
|
||||||
|
|
||||||
// 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 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 regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
let range = NSRange(message.startIndex..<message.endIndex, in: message)
|
let range = NSRange(status.startIndex..<status.endIndex, in: status)
|
||||||
let matches = regex?.matches(in: message, options: [], range: range) ?? []
|
let matches = regex?.matches(in: status, options: [], range: range) ?? []
|
||||||
#expect(!matches.isEmpty, "Message should match format, got: \(message)")
|
#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'")
|
@Test("Status message: automatic check doesn't set message when up-to-date")
|
||||||
func statusMessageUpdateReadyFormat() {
|
@MainActor
|
||||||
let testVersion = "9.9.9"
|
func automaticCheckUpToDateLeavesMessageNil() async {
|
||||||
let message = "Update to \(testVersion) is ready"
|
let checker = UpdateChecker()
|
||||||
|
|
||||||
// Verify the format matches the expected pattern
|
// Inject a stub appcast showing the current version (no update available)
|
||||||
let pattern = "^Update to [0-9]+\\.[0-9]+\\.[0-9]+ is ready$"
|
let currentVersion = UpdateChecker.currentVersion()
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
let stubAppcast = """
|
||||||
let range = NSRange(message.startIndex..<message.endIndex, in: message)
|
{
|
||||||
let matches = regex?.matches(in: message, options: [], range: range) ?? []
|
"version": "\(currentVersion)",
|
||||||
#expect(!matches.isEmpty, "Message should match format, got: \(message)")
|
"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() 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) }
|
||||||
|
|
||||||
|
// Create a real AppModel using the standard launch pattern
|
||||||
|
let model = AppDelegate.makeLaunchModel(appSupportRoot: appSupportRoot)
|
||||||
|
defer { model.hotkeys.unregisterAll() }
|
||||||
|
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user