Skip to content

Commit e1785d5

Browse files
authored
test: view model state transitions (#14)
1 parent 762c6ae commit e1785d5

File tree

3 files changed

+174
-1
lines changed

3 files changed

+174
-1
lines changed
Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,111 @@
11
import XCTest
2+
import Combine
23
@testable import FaceLiveness
4+
@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin
35

4-
final class LivenessTests: XCTestCase {}
6+
@MainActor
7+
final class FaceLivenessDetectionViewModelTestCase: XCTestCase {
8+
var videoChunker: VideoChunker!
9+
var viewModel: FaceLivenessDetectionViewModel!
10+
11+
override func setUp() {
12+
let faceDetector = MockFaceDetector()
13+
let videoChunker = VideoChunker(
14+
assetWriter: LivenessAVAssetWriter(),
15+
assetWriterDelegate: VideoChunker.AssetWriterDelegate(),
16+
assetWriterInput: LivenessAVAssetWriterInput()
17+
)
18+
let captureSession = LivenessCaptureSession(
19+
captureDevice: .init(avCaptureDevice: nil),
20+
outputDelegate: OutputSampleBufferCapturer(
21+
faceDetector: faceDetector,
22+
videoChunker: videoChunker
23+
)
24+
)
25+
26+
let viewModel = FaceLivenessDetectionViewModel(
27+
faceDetector: faceDetector,
28+
faceInOvalMatching: .init(instructor: .init()),
29+
captureSession: captureSession,
30+
videoChunker: videoChunker,
31+
closeButtonAction: {},
32+
sessionID: UUID().uuidString
33+
)
34+
35+
self.videoChunker = videoChunker
36+
self.viewModel = viewModel
37+
}
38+
39+
/// Given: A `FaceLivenessDetectionViewModel`
40+
/// When: The viewModel is first initialized
41+
/// Then: The state is `.intitial`
42+
func testInitialState() {
43+
viewModel.livenessService = MockLivenessService()
44+
XCTAssertEqual(viewModel.livenessState.state, .initial)
45+
}
46+
47+
/// Given: A `FaceLivenessDetectionViewModel`
48+
/// When: The viewModel is processes the happy path events
49+
/// Then: The end state of this flow is `.faceMatched`
50+
func testHappyPathToMatchedFace() async throws {
51+
viewModel.livenessService = MockLivenessService()
52+
53+
viewModel.livenessState.checkIsFacePrepared()
54+
XCTAssertEqual(viewModel.livenessState.state, .pendingFacePreparedConfirmation(.pendingCheck))
55+
56+
viewModel.initializeLivenessStream()
57+
viewModel.process(newResult: .noFace)
58+
XCTAssertEqual(videoChunker.state, .pending)
59+
60+
viewModel.sendInitialFaceDetectedEvent(
61+
initialFace: .zero,
62+
videoStartTime: Date().timestampMilliseconds
63+
)
64+
XCTAssertEqual(videoChunker.state, .writing)
65+
66+
let initialSegment = Data([0, 1])
67+
var currentSegment = Data([25, 42])
68+
XCTAssertFalse(viewModel.hasSentFirstVideo)
69+
let chunk = viewModel.chunk(initial: initialSegment, current: currentSegment)
70+
XCTAssertEqual(chunk, initialSegment + currentSegment)
71+
XCTAssertTrue(viewModel.hasSentFirstVideo)
72+
73+
currentSegment = Data([42, 25])
74+
let subsequentChunk = viewModel.chunk(initial: initialSegment, current: currentSegment)
75+
XCTAssertEqual(subsequentChunk, currentSegment)
76+
77+
viewModel.livenessState.faceMatched()
78+
XCTAssertEqual(viewModel.livenessState.state, .faceMatched)
79+
}
80+
81+
/// Given: A `FaceLivenessDetectionViewModel`
82+
/// When: The viewModel state `.countingDown` and receives
83+
/// an event from the face detector with `.noFace`
84+
/// Then: The flow bails with an `encounteredUnrecoverableError(.invalidFaceMovementDuringCountdown)`
85+
var stateChangeCancellable: Set<AnyCancellable>!
86+
func testInvalidFaceMovementDuringCountdown() {
87+
stateChangeCancellable = Set<AnyCancellable>()
88+
viewModel.livenessService = MockLivenessService()
89+
viewModel.livenessState.checkIsFacePrepared()
90+
viewModel.livenessState.startCountdown()
91+
XCTAssertEqual(viewModel.livenessState.state, .countingDown)
92+
93+
let stateChangeExpectation = expectation(
94+
description: "waiting on state change after invalid face movement"
95+
)
96+
97+
viewModel.$livenessState
98+
.drop(while: { $0.state == .countingDown })
99+
.sink { stateMachine in
100+
XCTAssertEqual(
101+
stateMachine.state,
102+
.encounteredUnrecoverableError(.invalidFaceMovementDuringCountdown)
103+
)
104+
stateChangeExpectation.fulfill()
105+
}
106+
.store(in: &stateChangeCancellable)
107+
108+
viewModel.process(newResult: .noFace)
109+
wait(for: [stateChangeExpectation], timeout: 0.1)
110+
}
111+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import AVFoundation
9+
@testable import FaceLiveness
10+
@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin
11+
12+
class MockFaceDetector: FaceDetector {
13+
func detectFaces(from buffer: CVPixelBuffer) {}
14+
func setResultHandler(detectionResultHandler: FaceLiveness.FaceDetectionResultHandler) {}
15+
init() {}
16+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Foundation
9+
@testable import FaceLiveness
10+
@_spi(PredictionsFaceLiveness) import AWSPredictionsPlugin
11+
12+
class MockLivenessService: LivenessService {
13+
var onInitialClientEvent: (LivenessEvent<InitialClientEvent>, Date) -> Void = { _, _ in }
14+
var onFaceDetectionEvent: (LivenessEvent<FaceDetection>, Date) -> Void = { _, _ in }
15+
var onFinalClientEvent: (LivenessEvent<FinalClientEvent>, Date) -> Void = { _, _ in }
16+
var onFreshnessEvent: (LivenessEvent<FreshnessEvent>, Date) -> Void = { _, _ in }
17+
var onVideoEvent: (LivenessEvent<VideoEvent>, Date) -> Void = { _, _ in }
18+
var onInitializeLivenessStream: (String, String) -> Void = { _, _ in }
19+
20+
func send<T>(_ event: LivenessEvent<T>, eventDate: () -> Date) {
21+
switch event {
22+
case let initialClient as LivenessEvent<InitialClientEvent>:
23+
onInitialClientEvent(initialClient, eventDate())
24+
case let faceDetection as LivenessEvent<FaceDetection>:
25+
onFaceDetectionEvent(faceDetection, eventDate())
26+
case let finalClient as LivenessEvent<FinalClientEvent>:
27+
onFinalClientEvent(finalClient, eventDate())
28+
case let freshness as LivenessEvent<FreshnessEvent>:
29+
onFreshnessEvent(freshness, eventDate())
30+
case let video as LivenessEvent<VideoEvent>:
31+
onVideoEvent(video, eventDate())
32+
default: break
33+
}
34+
}
35+
36+
func initializeLivenessStream(
37+
withSessionID sessionID: String, userAgent: String
38+
) throws {
39+
onInitializeLivenessStream(sessionID, userAgent)
40+
}
41+
42+
func register(
43+
onComplete: @escaping (ServerDisconnection) -> Void
44+
) {}
45+
46+
func register(
47+
listener: @escaping (FaceLivenessSession.SessionConfiguration) -> Void,
48+
on event: LivenessEventKind.Server
49+
) {}
50+
}

0 commit comments

Comments
 (0)