iOS/Tests/App/ZoneManager/ZoneManagerProcessor.test.s...

1262 lines
42 KiB
Swift

import CoreLocation
import Foundation
@testable import HomeAssistant
import PromiseKit
import RealmSwift
@testable import Shared
import XCTest
class ZoneManagerProcessorTests: XCTestCase {
private var apis: [FakeHassAPI]!
private var (oneShotLocationPromise, oneShotLocationSeal) = Promise<CLLocation>.pending()
private var (submitLocationPromise, submitLocationSeal) = Promise<Void>.pending()
private var delegate: FakeZoneManagerProcessorDelegate!
private var processor: ZoneManagerProcessorImpl!
private var realm: Realm!
private var circularRegion: CLCircularRegion!
private var circularRegionZone: RLMZone?
private var beaconRegion: CLBeaconRegion!
private var beaconRegionZone: RLMZone?
override func setUpWithError() throws {
try super.setUpWithError()
Current.connectivity.currentWiFiSSID = { "wifi_name" }
let executionIdentifier = UUID().uuidString
realm = try Realm(configuration: .init(inMemoryIdentifier: executionIdentifier))
Current.realm = { self.realm }
circularRegion = CLCircularRegion(
center: .init(latitude: 9.54, longitude: 3.05),
radius: 44,
identifier: "circular_region"
)
beaconRegion = CLBeaconRegion(
uuid: UUID(),
identifier: "beacon_region"
)
let servers = FakeServerManager(initial: 2)
let server1 = servers.all[0]
let server2 = servers.all[1]
apis = servers.all.map {
with(FakeHassAPI(server: $0)) {
let (newPromise, newSeal) = Promise<Void>.pending()
submitLocationPromise.pipe(to: newSeal.resolve)
$0.submitLocationPromise = newPromise
}
}
Current.servers = servers
Current.cachedApis = [server1.identifier: apis[0], server2.identifier: apis[1]]
Current.location.oneShotLocation = { _, _ in self.oneShotLocationPromise }
delegate = FakeZoneManagerProcessorDelegate()
processor = ZoneManagerProcessorImpl()
processor.delegate = delegate
}
func setUpZones(
circular: (CLCircularRegion, RLMZone) throws -> Void = { _, _ in },
beacon: (CLBeaconRegion, RLMZone) throws -> Void = { _, _ in }
) throws {
try realm.write {
circularRegionZone = with(RLMZone()) { zone in
zone.identifier = circularRegion.identifier
zone.Radius = circularRegion.radius
zone.Latitude = circularRegion.center.latitude
zone.Longitude = circularRegion.center.longitude
}
try circular(circularRegion, circularRegionZone!)
beaconRegionZone = with(RLMZone()) { zone in
zone.identifier = beaconRegion.identifier
}
try beacon(beaconRegion, beaconRegionZone!)
realm.add([circularRegionZone!, beaconRegionZone!])
}
}
func testNoAPIFails() throws {
Current.servers.removeAll()
let now = Date()
Current.date = { now }
let locations = [
CLLocation(
coordinate: .init(latitude: 123, longitude: 1.23),
altitude: 3.45,
horizontalAccuracy: 1.25,
verticalAccuracy: 0.36,
timestamp: now.addingTimeInterval(-5.0)
),
]
let promise = processor.perform(event: ZoneManagerEvent(eventType: .locationChange(locations)))
// we don't care which this flow wants
let oneShotLocation = CLLocation(latitude: 1, longitude: 1)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
XCTAssertNoThrow(try hang(promise))
}
func testPerformingOneShotErrors() throws {
Current.isPerformingSingleShotLocationQuery = true
let promise = processor.perform(event: ZoneManagerEvent(eventType: .locationChange([])))
XCTAssertEqual(try hangForIgnoreReason(promise), .duringOneShot)
Current.isPerformingSingleShotLocationQuery = false
}
func testPerformingEmptyLocations() throws {
let promise = processor.perform(event: ZoneManagerEvent(eventType: .locationChange([])))
XCTAssertEqual(try hangForIgnoreReason(promise), .locationMissingEntries)
}
func testLocationTooOld() throws {
let wasCatalyst = Current.isCatalyst
Current.isCatalyst = false
let now = Date()
Current.date = { now }
let locations = [
CLLocation(
coordinate: .init(latitude: 123, longitude: 1.23),
altitude: 3.45,
horizontalAccuracy: 1.25,
verticalAccuracy: 0.36,
timestamp: now.addingTimeInterval(-61.0)
),
CLLocation(
coordinate: .init(latitude: 123, longitude: 1.23),
altitude: 3.45,
horizontalAccuracy: 1.25,
verticalAccuracy: 0.36,
timestamp: now.addingTimeInterval(-31.0)
),
]
let promise = processor.perform(event: ZoneManagerEvent(eventType: .locationChange(locations)))
XCTAssertEqual(try hangForIgnoreReason(promise), .locationUpdateTooOld)
Current.isCatalyst = wasCatalyst
}
func testLocationTooOldOnCatalyst() throws {
let wasCatalyst = Current.isCatalyst
Current.isCatalyst = true
let now = Date()
Current.date = { now }
let locations = [
CLLocation(
coordinate: .init(latitude: 123, longitude: 1.23),
altitude: 3.45,
horizontalAccuracy: 1.25,
verticalAccuracy: 0.36,
timestamp: now.addingTimeInterval(-31.0)
),
]
let event = ZoneManagerEvent(eventType: .locationChange(locations))
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
// we don't care which this flow wants
let oneShotLocation = CLLocation(latitude: 1, longitude: 1)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertNil(api.submitLocationInvocation?.zone)
}
XCTAssertTrue(promise.isFulfilled)
Current.isCatalyst = wasCatalyst
}
func testLocationNotTooOld() throws {
let wasCatalyst = Current.isCatalyst
Current.isCatalyst = false
let now = Date()
Current.date = { now }
let locations = [
CLLocation(
coordinate: .init(latitude: 123, longitude: 1.23),
altitude: 3.45,
horizontalAccuracy: 1.25,
verticalAccuracy: 0.36,
timestamp: now.addingTimeInterval(-5.0)
),
]
let event = ZoneManagerEvent(eventType: .locationChange(locations))
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
// we don't care which this flow wants
let oneShotLocation = CLLocation(latitude: 1, longitude: 1)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertNil(api.submitLocationInvocation?.zone)
}
XCTAssertTrue(promise.isFulfilled)
Current.isCatalyst = wasCatalyst
}
func testPerformingZoneStateBad() throws {
let promise = processor.perform(event: ZoneManagerEvent(eventType: .region(circularRegion, .unknown)))
XCTAssertEqual(try hangForIgnoreReason(promise), .unknownRegionState)
}
func testNoAssociatedZone() throws {
let promise = processor.perform(event: ZoneManagerEvent(eventType: .region(circularRegion, .inside)))
XCTAssertEqual(try hangForIgnoreReason(promise), .unknownRegion)
}
func testTrackingDisabled() throws {
try setUpZones(circular: { _, zone in
zone.TrackingEnabled = false
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
))
XCTAssertEqual(try hangForIgnoreReason(promise), .zoneDisabled)
}
func testSSIDFiltered() throws {
try setUpZones(circular: { _, zone in
zone.SSIDFilter.append("wifi_name")
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
))
XCTAssertEqual(try hangForIgnoreReason(promise), .ignoredSSID("wifi_name"))
}
func testZoneAlreadyIn() throws {
// e.g. small zones getting multiple regions each with differing state
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
))
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
// we don't care which this flow wants
oneShotLocationSeal.fulfill(.init(latitude: 1, longitude: 1))
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
XCTAssertTrue(promise.isFulfilled)
}
func testZoneAlreadyOut() throws {
// e.g. small zones getting multiple regions each with differing state
try setUpZones(circular: { _, zone in
zone.inRegion = false
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
))
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
oneShotLocationSeal.fulfill(.init(latitude: 1, longitude: 1))
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
XCTAssertTrue(promise.isFulfilled)
}
func testZoneUpdatedToInside() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = false
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
))
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
oneShotLocationSeal.fulfill(.init(latitude: 1, longitude: 1))
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
}
func testZoneUpdatedToOutside() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
))
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
oneShotLocationSeal.fulfill(.init(latitude: 1, longitude: 1))
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
}
func testBeaconExitIgnored() throws {
try setUpZones(beacon: { _, zone in
zone.inRegion = true
})
let promise = processor
.perform(event: ZoneManagerEvent(
eventType: .region(beaconRegion, .outside),
associatedZone: beaconRegionZone
))
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
wait(for: [expectation], timeout: 10.0)
XCTAssertEqual(try hangForIgnoreReason(promise), .beaconExitIgnored)
// it should still update the zone
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
}
func testOneShot() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
let oneShotLocation = CLLocation(latitude: 3.33, longitude: 4.44)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testRegionEnterProducesInsideLocation() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = false
})
let event = ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
let oneShotLocation = { () -> CLLocation in
let coordinate = circularRegion.center.moving(
distance: .init(value: circularRegion.radius - 1, unit: .meters),
direction: .init(value: 80, unit: .degrees)
)
return CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
}()
XCTAssertTrue(circularRegion.contains(oneShotLocation.coordinate))
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testRegionExitProducesOutsideLocation() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
let oneShotLocation = { () -> CLLocation in
let coordinate = circularRegion.center.moving(
distance: .init(value: circularRegion.radius + 10, unit: .meters),
direction: .init(value: 80, unit: .degrees)
)
return CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
}()
XCTAssertTrue(!circularRegion.contains(oneShotLocation.coordinate))
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testRegionEnterProducesOutsideLocation() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = false
})
let event = ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
let oneShotLocation = { () -> CLLocation in
let coordinate = circularRegion.center.moving(
distance: .init(value: circularRegion.radius + 10, unit: .meters),
direction: .init(value: 80, unit: .degrees)
)
return CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
}()
XCTAssertFalse(circularRegion.contains(oneShotLocation.coordinate))
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
// difference! this is the mutated one!
if let sentLocation = api.submitLocationInvocation?.location {
let regionWithAccuracy = CLCircularRegion(
center: circularRegion.center,
radius: circularRegion.radius + sentLocation.horizontalAccuracy,
identifier: ""
)
XCTAssertTrue(regionWithAccuracy.contains(sentLocation.coordinate))
} else {
XCTFail("didn't send a location")
}
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testRegionEnterProducesOutsideZone() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = false
// we rely on the zone's values for this scenario's fuzzing
XCTAssertTrue(circularRegion.radius > 0 && circularRegion.radius < 100)
XCTAssertTrue(zone.Radius > 0 && zone.Radius < 100)
})
// grab the region that's the direction we're going in the one shot location below
let eventRegion = try XCTUnwrap(
circularRegionZone?.circularRegionsForMonitoring
.first(where: { $0.identifier.contains("240") })
)
let event = ZoneManagerEvent(
eventType: .region(eventRegion, .inside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
let distanceOut: CLLocationDistance = 14
let oneShotLocation = try { () -> CLLocation in
// moving toward one of the circle's centers guarantees we're pointed toward the intersection of all zones
let coordinate = circularRegion.center.moving(
distance: .init(value: circularRegion.radius + distanceOut, unit: .meters),
direction: .init(value: 30, unit: .degrees)
)
let location = CLLocation(
coordinate: coordinate,
altitude: 0,
horizontalAccuracy: distanceOut / 2.0 - 1,
// less than distance to zone, big enough to overlap all 3 other zones
verticalAccuracy: 0,
timestamp: Date()
)
XCTAssertTrue(try XCTUnwrap(circularRegionZone?.circularRegionsForMonitoring.allSatisfy({ region in
if region == eventRegion {
// we want to both fudge the accuracy for this region _and_ separately for the zone
return !region.containsWithAccuracy(location) &&
!circularRegion.containsWithAccuracy(
location.fuzzingAccuracy(
by: region.distanceWithAccuracy(from: location)
)
)
} else {
// this test case is assuming the location touches _all_ the regions except for the one entering
return region.containsWithAccuracy(location)
}
})))
// this test case is assuming this location does _not_ intersect the zone
XCTAssertFalse(circularRegion.containsWithAccuracy(location))
return location
}()
XCTAssertFalse(circularRegion.contains(oneShotLocation.coordinate))
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
if let sentLocation = api.submitLocationInvocation?.location {
let regionWithAccuracy = CLCircularRegion(
center: circularRegion.center,
radius: circularRegion.radius + sentLocation.horizontalAccuracy,
identifier: ""
)
XCTAssertTrue(regionWithAccuracy.contains(sentLocation.coordinate))
} else {
XCTFail("didn't send a location")
}
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testRegionEnterInsideAnotherZone() throws {
var outerRegion: CLCircularRegion!
var outerZone: RLMZone!
try setUpZones(circular: { region, zone in
zone.inRegion = false
outerZone = with(RLMZone()) {
$0.identifier = region.identifier + "-outer"
$0.Radius = region.radius * 2.0
$0.Latitude = region.center.latitude
$0.Longitude = region.center.longitude
}
outerRegion = outerZone.circularRegion
realm.add(outerZone)
// we rely on the zone's values for this scenario's fuzzing
XCTAssertTrue(circularRegion.radius > 0 && circularRegion.radius < 100)
XCTAssertTrue(zone.Radius > 0 && zone.Radius < 100)
})
XCTAssertNotNil(outerZone)
let event = ZoneManagerEvent(
eventType: .region(circularRegion, .inside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let oneShotLocationCoordinate = circularRegion.center.moving(
distance: .init(value: circularRegion.radius + 10, unit: .meters),
direction: .init(value: 0, unit: .degrees)
)
let oneShotLocation = CLLocation(
coordinate: oneShotLocationCoordinate,
altitude: -1,
horizontalAccuracy: 20,
verticalAccuracy: 0,
timestamp: Date()
)
XCTAssertFalse(circularRegion.contains(oneShotLocationCoordinate))
XCTAssertTrue(outerRegion.contains(oneShotLocationCoordinate))
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
let expectation = expectation(description: "promise")
promise.ensure { expectation.fulfill() }.cauterize()
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
if let sentLocation = api.submitLocationInvocation?.location {
XCTAssertTrue(circularRegion.contains(sentLocation.coordinate))
XCTAssertNotEqual(sentLocation.coordinate.toArray(), oneShotLocationCoordinate.toArray())
} else {
XCTFail("didn't send a location")
}
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testRegionEnterOneSub100ButNotAllInsideAnotherZone() throws {
// entering one region of a <100m zone, but not all, while still being inside another zone
// this is making sure we don't fuzz coordinates when we don't believe we're inside the new zone
var outerRegion: CLCircularRegion!
var outerZone: RLMZone!
var smallRegion: CLCircularRegion!
try setUpZones(circular: { region, zone in
outerZone = try with(RLMZone()) {
$0.identifier = region.identifier + "-outer"
$0.Radius = region.radius * 2.0
XCTAssert(zone.regionsForMonitoring.count > 1)
smallRegion = try XCTUnwrap(zone.regionsForMonitoring.first as? CLCircularRegion)
let offset = smallRegion.center.moving(
distance: .init(value: smallRegion.radius / 2.0, unit: .meters),
direction: .init(value: 0, unit: .degrees)
)
$0.Latitude = offset.latitude
$0.Longitude = offset.longitude
}
outerRegion = outerZone.circularRegion
realm.add(outerZone)
})
XCTAssertNotNil(outerZone)
let event = ZoneManagerEvent(
eventType: .region(smallRegion, .inside),
associatedZone: circularRegionZone
)
let promise = processor.perform(event: event)
let oneShotLocationCoordinate = outerRegion.center
// making sure we have the right test setup
XCTAssertFalse(circularRegion.contains(oneShotLocationCoordinate))
XCTAssertTrue(smallRegion.contains(oneShotLocationCoordinate))
XCTAssertTrue(outerRegion.contains(oneShotLocationCoordinate))
let oneShotLocation = CLLocation(
coordinate: oneShotLocationCoordinate,
altitude: -1,
horizontalAccuracy: 20,
verticalAccuracy: 0,
timestamp: Date()
)
XCTAssertFalse(circularRegion.contains(oneShotLocationCoordinate))
XCTAssertTrue(outerRegion.contains(oneShotLocationCoordinate))
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
let expectation = expectation(description: "promise")
promise.ensure { expectation.fulfill() }.cauterize()
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(circularRegionZone?.inRegion ?? false)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
if let sentLocation = api.submitLocationInvocation?.location {
XCTAssertEqual(sentLocation, oneShotLocation)
} else {
XCTFail("didn't send a location")
}
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testNotOneShot() throws {
try setUpZones(beacon: { _, zone in
zone.inRegion = false
})
let event = ZoneManagerEvent(
eventType: .region(beaconRegion, .inside),
associatedZone: beaconRegionZone
)
let promise = processor.perform(event: event)
let expectation = expectation(description: "promise")
promise.ensure {
expectation.fulfill()
}.cauterize()
submitLocationSeal.fulfill(())
wait(for: [expectation], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, event.associatedLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, beaconRegionZone)
}
if let state = delegate.states.first {
switch state {
case .didReceive(event):
// pass
break
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("no state but one was expected")
}
}
func testNotRecentlyUpdated() throws {
let now = Date()
Current.date = { now.addingTimeInterval(-60) }
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event1 = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise1 = processor.perform(event: event1)
let event2 = ZoneManagerEvent(eventType: .locationChange([CLLocation(latitude: 1.22, longitude: 2.11)]))
let promise2 = processor.perform(event: event2)
let expectation1 = expectation(description: "promise1")
let expectation2 = expectation(description: "promise2")
promise1.ensure {
expectation1.fulfill()
}.cauterize()
promise2.ensure {
expectation2.fulfill()
}.cauterize()
let oneShotLocation = CLLocation(latitude: 3.33, longitude: 4.44)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
processor.onCompletedEvent = {
Current.date = { now }
}
wait(for: [expectation1, expectation2], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event2.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, nil)
}
guard delegate.states.count == 2 else {
XCTFail("should have had 2 states")
return
}
let state1 = delegate.states[0]
let state2 = delegate.states[1]
switch state1 {
case .didReceive(event1): break // pass
default: XCTFail("incorrect type \(state1)")
}
switch state2 {
case .didReceive(event2): break // pass
default: XCTFail("incorrect type \(state2)")
}
}
func testRecentlyUpdatedSuccessfullyThenLocation() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event1 = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise1 = processor.perform(event: event1)
let event2 = ZoneManagerEvent(eventType: .locationChange([CLLocation(latitude: 1.22, longitude: 2.11)]))
let promise2 = processor.perform(event: event2)
let expectation1 = expectation(description: "promise1")
let expectation2 = expectation(description: "promise2")
promise1.ensure {
expectation1.fulfill()
}.cauterize()
promise2.ensure {
expectation2.fulfill()
}.cauterize()
let oneShotLocation = CLLocation(latitude: 3.33, longitude: 4.44)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation1, expectation2], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event1.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
guard delegate.states.count == 2 else {
XCTFail("should have had 2 states")
return
}
let state1 = delegate.states[0]
let state2 = delegate.states[1]
switch state1 {
case .didReceive(event1): break // pass
default: XCTFail("incorrect type \(state1)")
}
switch state2 {
case .didIgnore(event2, ZoneManagerIgnoreReason.recentlyUpdated): break // pass
default: XCTFail("incorrect type \(state2)")
}
}
func testRecentlyUpdatedSuccessfullyThenZone() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event1 = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise1 = processor.perform(event: event1)
let event2 = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise2 = processor.perform(event: event2)
let expectation1 = expectation(description: "promise1")
let expectation2 = expectation(description: "promise2")
promise1.ensure {
expectation1.fulfill()
}.cauterize()
promise2.ensure {
expectation2.fulfill()
}.cauterize()
let oneShotLocation = CLLocation(latitude: 3.33, longitude: 4.44)
oneShotLocationSeal.fulfill(oneShotLocation)
submitLocationSeal.fulfill(())
wait(for: [expectation1, expectation2], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event2.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, circularRegionZone)
}
guard delegate.states.count == 2 else {
XCTFail("should have had 2 states")
return
}
let state1 = delegate.states[0]
let state2 = delegate.states[1]
switch state1 {
case .didReceive(event1): break // pass
default: XCTFail("incorrect type \(state1)")
}
switch state2 {
case .didReceive(event2): break // pass
default: XCTFail("incorrect type \(state2)")
}
}
func testRecentlyUpdatedFailed() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event1 = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise1 = processor.perform(event: event1)
let oneShotLocation = CLLocation(latitude: 3.33, longitude: 4.44)
oneShotLocationSeal.fulfill(oneShotLocation)
enum FakeError: Error { case error }
submitLocationSeal.reject(FakeError.error)
(submitLocationPromise, submitLocationSeal) = Promise<Void>.pending()
submitLocationSeal.fulfill(())
for api in apis {
XCTAssertNil(api.submitLocationInvocation)
}
let event2 = ZoneManagerEvent(eventType: .locationChange([CLLocation(latitude: 1.22, longitude: 2.11)]))
let promise2 = processor.perform(event: event2)
let expectation1 = expectation(description: "promise1")
let expectation2 = expectation(description: "promise2")
promise1.ensure {
expectation1.fulfill()
}.cauterize()
promise2.ensure {
expectation2.fulfill()
}.cauterize()
wait(for: [expectation1, expectation2], timeout: 10.0)
XCTAssertFalse(circularRegionZone?.inRegion ?? true)
for api in apis {
XCTAssertEqual(api.submitLocationInvocation?.updateType, event2.asTrigger())
XCTAssertEqual(api.submitLocationInvocation?.location, oneShotLocation)
XCTAssertEqual(api.submitLocationInvocation?.zone, nil)
}
guard delegate.states.count == 2 else {
XCTFail("should have had 2 states")
return
}
let state1 = delegate.states[0]
let state2 = delegate.states[1]
switch state1 {
case .didReceive(event1): break // pass
default: XCTFail("incorrect type \(state1)")
}
switch state2 {
case .didReceive(event2): break // pass
default: XCTFail("incorrect type \(state2)")
}
}
func testConcurrentQueued() throws {
try setUpZones(circular: { _, zone in
zone.inRegion = true
})
let event1 = ZoneManagerEvent(
eventType: .region(circularRegion, .outside),
associatedZone: circularRegionZone
)
let promise1 = processor.perform(event: event1)
let event2 = ZoneManagerEvent(
eventType: .locationChange([.init(latitude: 1.22, longitude: 3.44)])
)
let promise2 = processor.perform(event: event2)
var onCompletedEventCount = 0
processor.onCompletedEvent = { [delegate] in
onCompletedEventCount += 1
XCTAssertEqual(delegate!.states.count, onCompletedEventCount)
}
let oneShotLocation = CLLocation(latitude: 3.33, longitude: 4.44)
oneShotLocationSeal.fulfill(oneShotLocation)
enum FakeError: Error { case error }
submitLocationSeal.reject(FakeError.error)
(submitLocationPromise, submitLocationSeal) = Promise<Void>.pending()
submitLocationSeal.fulfill(())
for api in apis {
XCTAssertNil(api.submitLocationInvocation)
}
let expectation1 = expectation(description: "promise1")
let expectation2 = expectation(description: "promise2")
promise1.ensure {
expectation1.fulfill()
}.cauterize()
promise2.ensure {
expectation2.fulfill()
}.cauterize()
wait(for: [expectation1, expectation2], timeout: 10.0)
}
// MARK: -
private func hangForIgnoreReason(_ promise: Promise<Void>) throws -> ZoneManagerIgnoreReason {
do {
try hang(promise)
XCTFail("expected error")
} catch let error as ZoneManagerIgnoreReason {
if let state = delegate.states.first {
switch state {
case let .didIgnore(_, innerError as ZoneManagerIgnoreReason):
XCTAssertEqual(innerError, error)
default:
XCTFail("incorrect state was logged")
}
} else {
XCTFail("delegate wasn't updated")
}
return error
} catch {
throw error
}
enum SomeError: Error {
case blah
}
throw SomeError.blah
}
}
private extension PromiseKit.Result where T == Void {
var ignoreReason: ZoneManagerIgnoreReason? {
switch self {
case let .rejected(error as ZoneManagerIgnoreReason):
return error
default:
return nil
}
}
}
class FakeZoneManagerProcessorDelegate: ZoneManagerProcessorDelegate {
var states = [ZoneManagerState]()
func processor(_ processor: ZoneManagerProcessor, didLog state: ZoneManagerState) {
states.append(state)
}
}
private class FakeHassAPI: HomeAssistantAPI {
var submitLocationPromise: Promise<Void>?
var submitLocationInvocation: (
updateType: LocationUpdateTrigger,
location: CLLocation?,
zone: RLMZone?
)?
override func SubmitLocation(
updateType: LocationUpdateTrigger,
location: CLLocation?,
zone: RLMZone?
) -> Promise<Void> {
submitLocationInvocation = (
updateType: updateType,
location: location,
zone: zone
)
return submitLocationPromise!
}
}