Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Liveness check manager tests #255

Open
wants to merge 2 commits into
base: liveness-timeout-images
Choose a base branch
from
Open
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 Example/SmileID.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
20B6D5EA2C21CA9E0023D51C /* CoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B6D5E92C21CA9E0023D51C /* CoreDataManager.swift */; };
20B6D5EC2C21CE660023D51C /* DataStoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B6D5EB2C21CE660023D51C /* DataStoreError.swift */; };
20C360C82C454C130008DBDE /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C360C72C454C130008DBDE /* RootViewModel.swift */; };
20DDAB3C2CE5F02C00F7F7BA /* LivenessCheckManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DDAB3B2CE5F02C00F7F7BA /* LivenessCheckManagerTests.swift */; };
20DFA0EC2C21917100AC2AE7 /* View+TextSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DFA0EB2C21917100AC2AE7 /* View+TextSelection.swift */; };
20F3D6F32C25F4D700B32751 /* (null) in Sources */ = {isa = PBXBuildFile; };
20F3D6F62C25F5C100B32751 /* SmileID.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 20F3D6F42C25F5C100B32751 /* SmileID.xcdatamodeld */; };
Expand Down Expand Up @@ -118,6 +119,7 @@
20B6D5E92C21CA9E0023D51C /* CoreDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManager.swift; sourceTree = "<group>"; };
20B6D5EB2C21CE660023D51C /* DataStoreError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreError.swift; sourceTree = "<group>"; };
20C360C72C454C130008DBDE /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = "<group>"; };
20DDAB3B2CE5F02C00F7F7BA /* LivenessCheckManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivenessCheckManagerTests.swift; sourceTree = "<group>"; };
20DFA0EB2C21917100AC2AE7 /* View+TextSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TextSelection.swift"; sourceTree = "<group>"; };
20F3D6F52C25F5C100B32751 /* SmileID.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = SmileID.xcdatamodel; sourceTree = "<group>"; };
23822FF3F5838ECB320564F5 /* Pods-SmileID_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Tests.release.xcconfig"; path = "Target Support Files/Pods-SmileID_Tests/Pods-SmileID_Tests.release.xcconfig"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -371,6 +373,7 @@
607FACE91AFB9204008FA782 /* Supporting Files */,
585BE4872AC7748E0091DDD8 /* RestartableTimerTest.swift */,
204C95A02CDA455600A07386 /* FaceValidatorTests.swift */,
20DDAB3B2CE5F02C00F7F7BA /* LivenessCheckManagerTests.swift */,
);
path = Tests;
sourceTree = "<group>";
Expand Down Expand Up @@ -749,6 +752,7 @@
918321EC2A52E36A00D6FB7F /* DependencyContainerTests.swift in Sources */,
204C95A12CDA455600A07386 /* FaceValidatorTests.swift in Sources */,
918321EA2A52E36A00D6FB7F /* URLSessionRestServiceClientTests.swift in Sources */,
20DDAB3C2CE5F02C00F7F7BA /* LivenessCheckManagerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
4 changes: 2 additions & 2 deletions Example/Tests/FaceValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import XCTest
@testable import SmileID

class FaceValidatorTests: XCTestCase {
var faceValidator: FaceValidator!
var mockDelegate: MockFaceValidatorDelegate!
private var faceValidator: FaceValidator!
private var mockDelegate: MockFaceValidatorDelegate!

override func setUp() {
super.setUp()
Expand Down
134 changes: 134 additions & 0 deletions Example/Tests/LivenessCheckManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import XCTest

@testable import SmileID

class LivenessCheckManagerTests: XCTestCase {
private var livenessCheckManager: LivenessCheckManager!
private var mockTimer: MockTimer!
private var dispatchQueueMock: DispatchQueueMock!
private var mockDelegate: MockLivenessCheckManagerDelegate!
private let taskTimeout: Int = 120

override func setUp() {
super.setUp()
mockTimer = MockTimer()
dispatchQueueMock = DispatchQueueMock()
livenessCheckManager = LivenessCheckManager(
taskTimer: mockTimer,
taskTimeoutDuration: TimeInterval(taskTimeout),
dispatchQueue: dispatchQueueMock,
livenessTaskSequence: LivenessTask.allCases
)
mockDelegate = MockLivenessCheckManagerDelegate()
livenessCheckManager.delegate = mockDelegate
}

override func tearDown() {
livenessCheckManager = nil
mockTimer = nil
dispatchQueueMock = nil
mockDelegate = nil
super.tearDown()
}

func testInitializationShufflesTasks() {
let manager1 = LivenessCheckManager()
let manager2 = LivenessCheckManager()

XCTAssertNotEqual(
manager1.livenessTaskSequence, manager2.livenessTaskSequence,
"Task sequences should be shuffled differently")
}

func testInitiateSetsCurrentTask() {
livenessCheckManager.initiateLivenessCheck()
XCTAssertNotNil(
livenessCheckManager.currentTask,
"Current task should be set after initiating liveness check.")
}

func testCompletesAllLivenessTasksInSequence() {
livenessCheckManager.initiateLivenessCheck()

XCTAssertEqual(livenessCheckManager.currentTask, .lookLeft)

// complete look left
let lookLeftFaceGeometry = FaceGeometryData(
boundingBox: .zero,
roll: 0,
yaw: -0.3,
pitch: 0,
direction: .none
)
livenessCheckManager.processFaceGeometry(lookLeftFaceGeometry)
XCTAssertTrue(mockDelegate.didCompleteLivenessTaskCalled, "Delegate should be notified of task completed")
XCTAssertEqual(
livenessCheckManager.lookLeftProgress, 1.0, "Look left progress should be complete")

// advance to next task
XCTAssertEqual(livenessCheckManager.currentTask, .lookRight)

// complete look right
let lookRightFaceGeometry = FaceGeometryData(
boundingBox: .zero,
roll: 0,
yaw: 0.3,
pitch: 0,
direction: .none
)
livenessCheckManager.processFaceGeometry(lookRightFaceGeometry)
XCTAssertTrue(mockDelegate.didCompleteLivenessTaskCalled, "Delegate should be notified of task completed")
XCTAssertEqual(
livenessCheckManager.lookRightProgress, 1.0, "Look right progress should be complete")

// advance to next task
XCTAssertEqual(livenessCheckManager.currentTask, .lookUp)

// complete look up
let lookUpFaceGeometry = FaceGeometryData(
boundingBox: .zero,
roll: 0,
yaw: 0,
pitch: -0.3,
direction: .none
)
livenessCheckManager.processFaceGeometry(lookUpFaceGeometry)
XCTAssertTrue(mockDelegate.didCompleteLivenessTaskCalled, "Delegate should be notified of task completed")
XCTAssertEqual(
livenessCheckManager.lookUpProgress, 1.0, "Look up progress should be complete")

XCTAssertTrue(mockDelegate.didCompleteLivenessChallengeCalled)
}

func testTaskTimeout() {
livenessCheckManager.initiateLivenessCheck()
for _ in 0..<taskTimeout {
self.mockTimer.fire()
}
XCTAssertTrue(mockDelegate.didTimeoutCalled, "Delegate should be notified of task timeout.")
}
}

final class MockLivenessCheckManagerDelegate: LivenessCheckManagerDelegate {
var didCompleteLivenessTaskCalled: Bool = false
var didCompleteLivenessChallengeCalled: Bool = false
var didTimeoutCalled: Bool = false

func didCompleteLivenessTask() {
didCompleteLivenessTaskCalled = true
}

func didCompleteLivenessChallenge() {
didCompleteLivenessChallengeCalled = true
}

func livenessChallengeTimeout() {
didTimeoutCalled = true
}
}

final class DispatchQueueMock: DispatchQueueType {
func async(execute work: @escaping @convention(block) () -> Void) {
work()
}
}
53 changes: 32 additions & 21 deletions Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import Vision

/// Represents the different tasks in an active liveness check.
enum LivenessTask {
enum LivenessTask: CaseIterable {
case lookLeft
case lookRight
case lookUp
Expand All @@ -16,7 +16,7 @@ protocol LivenessCheckManagerDelegate: AnyObject {

class LivenessCheckManager: ObservableObject {
/// The sequence of liveness tasks to be performed.
private var livenessTaskSequence: [LivenessTask] = []
private(set) var livenessTaskSequence: [LivenessTask] = []
/// The index pointing to the current task in the sequence.
private var currentTaskIndex: Int = 0

Expand All @@ -32,30 +32,35 @@ class LivenessCheckManager: ObservableObject {
/// The maximum threshold for pitch (up-down head movement)
private let maxPitchAngleThreshold: CGFloat = 0.3
/// The timeout duration for each task in seconds.
private let taskTimeoutDuration: TimeInterval = 120
private let taskTimeoutDuration: TimeInterval

// MARK: Face Orientation Properties
@Published var lookLeftProgress: CGFloat = 0.0
@Published var lookRightProgress: CGFloat = 0.0
@Published var lookUpProgress: CGFloat = 0.0

/// The current liveness task.
private(set) var currentTask: LivenessTask? {
var currentTask: LivenessTask? {
didSet {
if currentTask != nil {
resetTaskTimer()
} else {
stopTaskTimer()
}
handleTaskChange()
}
}
/// The timer used for task timeout.
private var taskTimer: Timer?
private let taskTimer: TimerProtocol
private let dispatchQueue: DispatchQueueType
private var elapsedTime: TimeInterval = 0.0

/// Initializes the LivenessCheckManager with a shuffled set of tasks.
init() {
livenessTaskSequence = [.lookLeft, .lookRight, .lookUp].shuffled()
init(
taskTimer: TimerProtocol = RealTimer(),
taskTimeoutDuration: TimeInterval = 120,
dispatchQueue: DispatchQueueType = DispatchQueue.main,
livenessTaskSequence: [LivenessTask] = LivenessTask.allCases.shuffled()
) {
self.taskTimer = taskTimer
self.taskTimeoutDuration = taskTimeoutDuration
self.dispatchQueue = dispatchQueue
self.livenessTaskSequence = livenessTaskSequence
}

/// Cleans up resources when the manager is no longer needed.
Expand All @@ -65,11 +70,11 @@ class LivenessCheckManager: ObservableObject {

/// Resets the task timer to the initial timeout duration.
private func resetTaskTimer() {
guard taskTimer == nil else { return }
DispatchQueue.main.async {
self.taskTimer = Timer.scheduledTimer(
withTimeInterval: 1.0,
repeats: true) { [weak self] _ in
stopTaskTimer()
elapsedTime = 0.0

dispatchQueue.async {
self.taskTimer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.taskTimerFired()
}
}
Expand All @@ -84,9 +89,7 @@ class LivenessCheckManager: ObservableObject {

/// Stops the current task timer.
private func stopTaskTimer() {
guard taskTimer != nil else { return }
taskTimer?.invalidate()
taskTimer = nil
taskTimer.invalidate()
}

/// Handles the timeout event for a task.
Expand All @@ -95,6 +98,14 @@ class LivenessCheckManager: ObservableObject {
delegate?.livenessChallengeTimeout()
}

private func handleTaskChange() {
if currentTask != nil {
resetTaskTimer()
} else {
stopTaskTimer()
}
}

/// Advances to the next task in the sequence
/// - Returns: `true` if there is a next task, `false` if all tasks are completed.
private func advanceToNextTask() -> Bool {
Expand All @@ -108,7 +119,7 @@ class LivenessCheckManager: ObservableObject {

/// Sets the initial task for the liveness check.
func initiateLivenessCheck() {
currentTask = livenessTaskSequence[currentTaskIndex]
currentTask = livenessTaskSequence.first
}

/// Processes face geometry data and checks for task completion
Expand Down
11 changes: 11 additions & 0 deletions Sources/SmileID/Classes/Helpers/DispatchQueueType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

protocol DispatchQueueType {
func async(execute work: @escaping @convention(block) () -> Void)
}

extension DispatchQueue: DispatchQueueType {
func async(execute work: @escaping @convention(block) () -> Void) {
async(group: nil, qos: .unspecified, flags: [], execute: work)
}
}
54 changes: 54 additions & 0 deletions Sources/SmileID/Classes/Helpers/TimerProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

protocol TimerProtocol {
func scheduledTimer(
withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (TimerProtocol) -> Void)
func invalidate()
}

class RealTimer: TimerProtocol {
private var timer: Timer?
private let lock = NSLock()

func scheduledTimer(
withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (any TimerProtocol) -> Void
) {
defer { lock.unlock() }
timer = Timer.scheduledTimer(
withTimeInterval: interval, repeats: repeats,
block: { [weak self] _ in
guard let self = self else { return }
block(self)
})
}

func invalidate() {
lock.lock()
defer { lock.unlock() }
timer?.invalidate()
timer = nil
}
}

class MockTimer: TimerProtocol {
private var isInvalidated: Bool = false
private var interval: TimeInterval?
var repeats: Bool?
private var block: ((TimerProtocol) -> Void)?

func scheduledTimer(
withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (any TimerProtocol) -> Void
) {
self.interval = interval
self.repeats = repeats
self.block = block
}

func invalidate() {
isInvalidated = true
}

func fire() {
block?(self)
}
}
Loading