Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@
42881BD42DDF12340079BDCB /* SwiftUI+SafeArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42881BD32DDF12340079BDCB /* SwiftUI+SafeArea.swift */; };
428830EB2C6E3A8D0012373D /* WatchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */; };
428830ED2C6E3A9A0012373D /* WatchHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */; };
4288635F2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4288635E2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift */; };
428863552EF963FF00319CF4 /* CameraListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428863522EF963FF00319CF4 /* CameraListView.swift */; };
428863562EF963FF00319CF4 /* CameraListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428863532EF963FF00319CF4 /* CameraListViewModel.swift */; };
428863592EF9641400319CF4 /* OpenCameraListAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428863572EF9641400319CF4 /* OpenCameraListAppIntent.swift */; };
Expand Down Expand Up @@ -2395,6 +2396,7 @@
42881BD32DDF12340079BDCB /* SwiftUI+SafeArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+SafeArea.swift"; sourceTree = "<group>"; };
428830EA2C6E3A8D0012373D /* WatchHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeView.swift; sourceTree = "<group>"; };
428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeViewModel.swift; sourceTree = "<group>"; };
4288635E2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerLocalPushInterfaceExtension.test.swift; sourceTree = "<group>"; };
428863522EF963FF00319CF4 /* CameraListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraListView.swift; sourceTree = "<group>"; };
428863532EF963FF00319CF4 /* CameraListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraListViewModel.swift; sourceTree = "<group>"; };
428863572EF9641400319CF4 /* OpenCameraListAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCameraListAppIntent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6338,6 +6340,7 @@
B657A8FF1CA646EB00121384 /* App */ = {
isa = PBXGroup;
children = (
4288635E2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift */,
42B89EAB2E080494000224A2 /* AppConstants.test.swift */,
42646B762E0BE6F100F6B367 /* BackgroundTask.test.swift */,
11EFD3C1272642FC000AF78B /* Additions */,
Expand Down Expand Up @@ -9060,6 +9063,7 @@
42A47A8A2C452DB500C9B43D /* MockWebViewController.swift in Sources */,
42ACC2A72E9F9B300045A3FD /* URLExtensions.test.swift in Sources */,
42ACC2862E9E74C80045A3FD /* BaseOnboardingView.test.swift in Sources */,
4288635F2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift in Sources */,
11A71C7324A4FC8A00D9565F /* ZoneManagerEquatableRegion.test.swift in Sources */,
429481EB2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift in Sources */,
119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,3 +576,47 @@ extension NotificationManagerLocalPushInterfaceExtension {
}
}
}

// MARK: - Testing Support

#if DEBUG
extension NotificationManagerLocalPushInterfaceExtension {
/// Test-only access to reconnection state for verification
var testReconnectionAttempt: Int {
reconnectionAttempt
}

/// Test-only access to check if reconnection timer is active
var testHasActiveReconnectionTimer: Bool {
reconnectionTimer != nil
}

/// Test-only access to disconnected servers set
var testDisconnectedServers: Set<Identifier<Server>> {
disconnectedServers
}

/// Test-only access to reconnection delays for verification
var testReconnectionDelays: [TimeInterval] {
reconnectionDelays
}

/// Test-only method to manually trigger reconnection scheduling
/// This allows tests to verify the reconnection flow without waiting for real disconnections
func testScheduleReconnection() {
scheduleReconnection()
}

/// Test-only method to manually trigger reconnection attempt
/// This allows tests to verify the attempt counter increment and reload logic
func testAttemptReconnection() {
attemptReconnection()
}

/// Test-only method to manually cancel reconnection
/// This allows tests to verify cleanup logic
func testCancelReconnection() {
cancelReconnection()
}
}
#endif
278 changes: 278 additions & 0 deletions Tests/App/NotificationManagerLocalPushInterfaceExtension.test.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import Foundation
import HAKit
@testable import HomeAssistant
import NetworkExtension
import PromiseKit
@testable import Shared
import XCTest

final class NotificationManagerLocalPushInterfaceExtensionTests: XCTestCase {
private var interface: NotificationManagerLocalPushInterfaceExtension!
private var fakeServers: FakeServerManager!

override func setUp() {
super.setUp()

fakeServers = FakeServerManager()
Current.servers = fakeServers

interface = NotificationManagerLocalPushInterfaceExtension()
}

override func tearDown() {
super.tearDown()

interface = nil
}

// MARK: - Reconnection Backoff Tests

func testReconnectionDelaysAreCorrect() {
// Verify the reconnection delays array contains expected values: 5s, 10s, 30s
let delays = interface.testReconnectionDelays
XCTAssertEqual(delays.count, 3, "Should have 3 delay values")
XCTAssertEqual(delays[0], 5, "First delay should be 5 seconds")
XCTAssertEqual(delays[1], 10, "Second delay should be 10 seconds")
XCTAssertEqual(delays[2], 30, "Third delay should be 30 seconds (cap)")
}

func testReconnectionAttemptCounterStartsAtZero() {
// Initial state should have no reconnection attempts
XCTAssertEqual(interface.testReconnectionAttempt, 0, "Reconnection attempt should start at 0")
XCTAssertFalse(interface.testHasActiveReconnectionTimer, "No timer should be active initially")
}

func testScheduleReconnectionCreatesTimer() {
// Scheduling a reconnection should create an active timer
interface.testScheduleReconnection()

XCTAssertTrue(interface.testHasActiveReconnectionTimer, "Timer should be active after scheduling")
XCTAssertEqual(interface.testReconnectionAttempt, 0, "Attempt counter should still be 0 after scheduling")
}

func testAttemptReconnectionIncrementsCounter() {
// Attempting reconnection should increment the counter
let initialAttempt = interface.testReconnectionAttempt
interface.testAttemptReconnection()

XCTAssertEqual(
interface.testReconnectionAttempt,
initialAttempt + 1,
"Attempt counter should increment by 1"
)
}

func testMultipleReconnectionAttemptsIncrementCorrectly() {
// Multiple attempts should keep incrementing
XCTAssertEqual(interface.testReconnectionAttempt, 0)

interface.testAttemptReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 1)

interface.testAttemptReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 2)

interface.testAttemptReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 3)

// Even after many attempts, counter should keep incrementing
// The delay will be capped at 30s but counter continues
}

func testReconnectionDelaySelectionLogic() {
// This test documents the delay selection algorithm:
// delayIndex = min(reconnectionAttempt, reconnectionDelays.count - 1)
// With delays = [5, 10, 30]:
// - Attempt 0 -> index min(0, 2) = 0 -> 5s
// - Attempt 1 -> index min(1, 2) = 1 -> 10s
// - Attempt 2 -> index min(2, 2) = 2 -> 30s
// - Attempt 3+ -> index min(3+, 2) = 2 -> 30s (capped)

let delays = interface.testReconnectionDelays
let maxIndex = delays.count - 1

// Simulate delay selection for different attempt counts
for attempt in 0 ..< 10 {
let delayIndex = min(attempt, maxIndex)
let expectedDelay = delays[delayIndex]

if attempt == 0 {
XCTAssertEqual(expectedDelay, 5, "Attempt 0 should use 5s delay")
} else if attempt == 1 {
XCTAssertEqual(expectedDelay, 10, "Attempt 1 should use 10s delay")
} else {
XCTAssertEqual(expectedDelay, 30, "Attempt \(attempt) should use 30s delay (capped)")
}
}
}

// MARK: - Timer Cancellation Tests

func testCancelReconnectionClearsTimer() {
// Schedule a reconnection to create a timer
interface.testScheduleReconnection()
XCTAssertTrue(interface.testHasActiveReconnectionTimer, "Timer should be active")

// Cancel should clear the timer
interface.testCancelReconnection()
XCTAssertFalse(interface.testHasActiveReconnectionTimer, "Timer should be cleared after cancellation")
}

func testCancelReconnectionResetsAttemptCounter() {
// Increment attempt counter
interface.testAttemptReconnection()
interface.testAttemptReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 2)

// Cancel should reset counter to 0
interface.testCancelReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 0, "Attempt counter should be reset to 0")
}

func testCancelReconnectionWithoutActiveTimerIsNoOp() {
// Calling cancel without an active timer should be safe
XCTAssertFalse(interface.testHasActiveReconnectionTimer)
XCTAssertEqual(interface.testReconnectionAttempt, 0)

interface.testCancelReconnection()

XCTAssertFalse(interface.testHasActiveReconnectionTimer)
XCTAssertEqual(interface.testReconnectionAttempt, 0)
}

func testScheduleReconnectionCancelsExistingTimer() {
// Schedule first reconnection
interface.testScheduleReconnection()
XCTAssertTrue(interface.testHasActiveReconnectionTimer)

// Attempt reconnection to increment counter
interface.testAttemptReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 1)

// Schedule again - should cancel existing timer and create new one
interface.testScheduleReconnection()
XCTAssertTrue(interface.testHasActiveReconnectionTimer, "New timer should be active")
// Attempt counter should remain (not reset by schedule, only by cancel)
XCTAssertEqual(interface.testReconnectionAttempt, 1)
}

// MARK: - State Tracking Tests

func testDisconnectedServersSetStartsEmpty() {
// Initially, no servers should be disconnected
XCTAssertTrue(interface.testDisconnectedServers.isEmpty, "Disconnected servers set should start empty")
}

func testDisconnectedServersTracking() {
// This test documents that disconnected servers are tracked internally
// The actual tracking happens in the status(for:) method based on sync state
// Since we can't easily mock NEAppPushManager, we verify the Set is accessible

let initialCount = interface.testDisconnectedServers.count
XCTAssertEqual(initialCount, 0, "Should start with no disconnected servers")

// The actual population of this set happens when:
// 1. A server's sync state becomes .unavailable
// 2. The server has an active manager
// 3. status(for:) is called

// These conditions require NEAppPushManager which we can't easily mock in unit tests
}

// MARK: - Integration Behavior Documentation

func testReconnectionFlowDocumentation() {
// This test documents the expected reconnection flow:
// 1. Server becomes unavailable -> added to disconnectedServers set
// 2. scheduleReconnection() called -> timer created with appropriate delay
// 3. Timer fires -> attemptReconnection() called
// 4. attemptReconnection() increments counter and calls reloadManagersAfterSave()
// 5. If server reconnects -> removed from disconnectedServers set
// 6. If all servers reconnect -> cancelReconnection() called
// 7. cancelReconnection() clears timer and resets attempt counter

// Verify initial state
XCTAssertEqual(interface.testReconnectionAttempt, 0)
XCTAssertFalse(interface.testHasActiveReconnectionTimer)
XCTAssertTrue(interface.testDisconnectedServers.isEmpty)

// Simulate reconnection flow
interface.testScheduleReconnection()
XCTAssertTrue(interface.testHasActiveReconnectionTimer, "Step 2: Timer should be created")

interface.testAttemptReconnection()
XCTAssertEqual(interface.testReconnectionAttempt, 1, "Step 3: Counter should increment")

interface.testCancelReconnection()
XCTAssertFalse(interface.testHasActiveReconnectionTimer, "Step 7: Timer should be cleared")
XCTAssertEqual(interface.testReconnectionAttempt, 0, "Step 7: Counter should be reset")
}

func testExponentialBackoffWithCapDocumentation() {
// Document the exponential backoff behavior
// Delays: [5, 10, 30]
// Formula: delays[min(attemptNumber, delays.count - 1)]

let delays = interface.testReconnectionDelays

// First attempt (0): 5 seconds
XCTAssertEqual(delays[min(0, delays.count - 1)], 5)

// Second attempt (1): 10 seconds
XCTAssertEqual(delays[min(1, delays.count - 1)], 10)

// Third attempt (2): 30 seconds
XCTAssertEqual(delays[min(2, delays.count - 1)], 30)

// Fourth and subsequent attempts: capped at 30 seconds
XCTAssertEqual(delays[min(3, delays.count - 1)], 30)
XCTAssertEqual(delays[min(4, delays.count - 1)], 30)
XCTAssertEqual(delays[min(10, delays.count - 1)], 30)
}

func testTimerBehaviorWithMultipleServers() {
// Document expected behavior with multiple servers:
// - When any server disconnects: schedule reconnection if not already scheduled
// - When a server reconnects: remove from disconnectedServers set
// - When all servers reconnect: cancel reconnection timer
// - When some servers remain disconnected: timer remains active

// This behavior is implemented in status(for:) method and can be tested
// in integration tests with actual NEAppPushManager instances

XCTAssertTrue(true, "Behavior documented - requires integration testing")
}

func testRapidStateChangesHandling() {
// Document that rapid state changes are handled correctly:
// - Multiple disconnections don't create duplicate entries
// - Reconnection during active timer cancels and resets properly
// - State transitions are idempotent

// Simulate multiple schedule/cancel cycles
for _ in 0 ..< 5 {
interface.testScheduleReconnection()
XCTAssertTrue(interface.testHasActiveReconnectionTimer)

interface.testCancelReconnection()
XCTAssertFalse(interface.testHasActiveReconnectionTimer)
XCTAssertEqual(interface.testReconnectionAttempt, 0)
}

// Final state should be clean
XCTAssertFalse(interface.testHasActiveReconnectionTimer)
XCTAssertEqual(interface.testReconnectionAttempt, 0)
}

func testAttemptCounterContinuesIndefinitely() {
// Document that attempt counter continues beyond delay array length
// (delay caps at 30s but counter keeps going for tracking purposes)

for expectedAttempt in 0 ..< 20 {
XCTAssertEqual(interface.testReconnectionAttempt, expectedAttempt)
interface.testAttemptReconnection()
}

XCTAssertEqual(interface.testReconnectionAttempt, 20)
}
}
Loading