Files
yattee/YatteeTests/SeekGestureTests.swift
2026-02-08 18:33:56 +01:00

468 lines
18 KiB
Swift

//
// SeekGestureTests.swift
// YatteeTests
//
// Tests for horizontal seek gesture models and algorithm.
//
import Foundation
import Testing
@testable import Yattee
@Suite("Seek Gesture Tests")
struct SeekGestureTests {
// MARK: - SeekGestureSensitivity Tests
@Suite("SeekGestureSensitivity")
struct SeekGestureSensitivityTests {
@Test("Base seconds per screen width values")
func baseSecondsPerScreenWidth() {
#expect(SeekGestureSensitivity.low.baseSecondsPerScreenWidth == 30)
#expect(SeekGestureSensitivity.medium.baseSecondsPerScreenWidth == 60)
#expect(SeekGestureSensitivity.high.baseSecondsPerScreenWidth == 120)
}
@Test("All sensitivities have display names")
func displayNames() {
for sensitivity in SeekGestureSensitivity.allCases {
#expect(!sensitivity.displayName.isEmpty)
#expect(!sensitivity.description.isEmpty)
}
}
@Test("Sensitivity is codable")
func isCodable() throws {
for original in SeekGestureSensitivity.allCases {
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(SeekGestureSensitivity.self, from: encoded)
#expect(decoded == original)
}
}
}
// MARK: - SeekGestureSettings Tests
@Suite("SeekGestureSettings")
struct SeekGestureSettingsTests {
@Test("Default settings are disabled")
func defaultSettingsAreDisabled() {
let settings = SeekGestureSettings.default
#expect(settings.isEnabled == false)
}
@Test("Default sensitivity is medium")
func defaultSensitivityIsMedium() {
let settings = SeekGestureSettings.default
#expect(settings.sensitivity == .medium)
}
@Test("Settings is codable")
func isCodable() throws {
let original = SeekGestureSettings(isEnabled: true, sensitivity: .high)
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(SeekGestureSettings.self, from: encoded)
#expect(decoded.isEnabled == original.isEnabled)
#expect(decoded.sensitivity == original.sensitivity)
}
@Test("Settings is hashable")
func isHashable() {
let settings1 = SeekGestureSettings(isEnabled: true, sensitivity: .low)
let settings2 = SeekGestureSettings(isEnabled: true, sensitivity: .low)
let settings3 = SeekGestureSettings(isEnabled: false, sensitivity: .high)
#expect(settings1 == settings2)
#expect(settings1 != settings3)
#expect(settings1.hashValue == settings2.hashValue)
}
}
// MARK: - SeekGestureCalculator Tests
@Suite("SeekGestureCalculator")
struct SeekGestureCalculatorTests {
// MARK: - Horizontal Movement Detection
@Suite("isHorizontalMovement")
struct IsHorizontalMovementTests {
@Test("Horizontal movement exceeding threshold is recognized")
func horizontalExceedingThreshold() {
// 25pt horizontal, 0pt vertical - should be recognized
let translation = CGSize(width: 25, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == true)
}
@Test("Horizontal movement below threshold is not recognized")
func horizontalBelowThreshold() {
// 15pt horizontal - below 20pt threshold
let translation = CGSize(width: 15, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
@Test("Negative horizontal movement is recognized")
func negativeHorizontal() {
// -30pt horizontal (backward direction)
let translation = CGSize(width: -30, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == true)
}
@Test("Diagonal within 30 degrees is recognized")
func diagonalWithinAngle() {
// tan(30°) 0.577, so for 50pt horizontal, max vertical 28.9pt
let translation = CGSize(width: 50, height: 25)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == true)
}
@Test("Diagonal beyond 30 degrees is not recognized")
func diagonalBeyondAngle() {
// 45 degree angle - beyond 30 degree limit
let translation = CGSize(width: 30, height: 30)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
@Test("Vertical movement is not recognized")
func verticalMovement() {
// Purely vertical
let translation = CGSize(width: 5, height: 50)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
@Test("Zero movement is not recognized")
func zeroMovement() {
let translation = CGSize(width: 0, height: 0)
#expect(SeekGestureCalculator.isHorizontalMovement(translation: translation) == false)
}
}
// MARK: - Duration Multiplier
@Suite("calculateDurationMultiplier")
struct DurationMultiplierTests {
@Test("5 minute video gives minimum multiplier")
func fiveMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 300)
#expect(multiplier == 0.5)
}
@Test("10 minute video gives 1.0 multiplier")
func tenMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 600)
#expect(multiplier == 1.0)
}
@Test("20 minute video gives 2.0 multiplier")
func twentyMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 1200)
#expect(multiplier == 2.0)
}
@Test("30 minute video gives maximum multiplier")
func thirtyMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 1800)
#expect(multiplier == 3.0)
}
@Test("60 minute video is capped at maximum")
func sixtyMinuteVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 3600)
#expect(multiplier == 3.0)
}
@Test("Very short video is capped at minimum")
func veryShortVideo() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 60)
#expect(multiplier == 0.5)
}
@Test("Zero duration returns default")
func zeroDuration() {
let multiplier = SeekGestureCalculator.calculateDurationMultiplier(videoDuration: 0)
#expect(multiplier == 1.0)
}
}
// MARK: - Seek Delta Calculation
@Suite("calculateSeekDelta")
struct SeekDeltaTests {
@Test("Full screen swipe with medium sensitivity on 10 min video")
func fullSwipeMedium() {
// 10 min video, medium sensitivity, full screen swipe
// Expected: 60s * 1.0 = 60s
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == 60)
}
@Test("Half screen swipe")
func halfScreenSwipe() {
// Half screen swipe should give half the seek delta
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 200,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == 30)
}
@Test("Negative swipe for backward seek")
func negativeSwipe() {
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: -200,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == -30)
}
@Test("Low sensitivity gives smaller seek")
func lowSensitivity() {
// Low = 30s base, vs medium = 60s
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 600,
sensitivity: .low
)
#expect(delta == 30)
}
@Test("High sensitivity gives larger seek")
func highSensitivity() {
// High = 120s base, vs medium = 60s
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 600,
sensitivity: .high
)
#expect(delta == 120)
}
@Test("Short video reduces seek amount")
func shortVideoMultiplier() {
// 5 min video has 0.5x multiplier
// Expected: 60s * 0.5 = 30s for full swipe
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 300,
sensitivity: .medium
)
#expect(delta == 30)
}
@Test("Long video increases seek amount")
func longVideoMultiplier() {
// 30 min video has 3.0x multiplier
// Expected: 60s * 3.0 = 180s for full swipe
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 400,
screenWidth: 400,
videoDuration: 1800,
sensitivity: .medium
)
#expect(delta == 180)
}
@Test("Small drag below minimum threshold returns nil")
func belowMinimumThreshold() {
// Very small drag that would result in < 5s seek
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 10,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == nil)
}
@Test("Drag at exactly minimum threshold returns value")
func atMinimumThreshold() {
// Calculate drag distance needed for exactly 5s
// 5s = (drag/400) * 60 * 1.0 drag = 400 * 5 / 60 33.3pt
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 34,
screenWidth: 400,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta != nil)
if let delta {
#expect(delta >= 5.0)
}
}
@Test("Zero screen width returns nil")
func zeroScreenWidth() {
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 100,
screenWidth: 0,
videoDuration: 600,
sensitivity: .medium
)
#expect(delta == nil)
}
@Test("Zero duration returns nil")
func zeroDuration() {
let delta = SeekGestureCalculator.calculateSeekDelta(
dragDistance: 100,
screenWidth: 400,
videoDuration: 0,
sensitivity: .medium
)
#expect(delta == nil)
}
}
// MARK: - Boundary Clamping
@Suite("clampSeekTime")
struct ClampSeekTimeTests {
@Test("Normal forward seek within bounds")
func normalForwardSeek() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 100,
seekDelta: 50,
duration: 600
)
#expect(result.seekTime == 150)
#expect(result.hitBoundary == false)
}
@Test("Normal backward seek within bounds")
func normalBackwardSeek() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 100,
seekDelta: -50,
duration: 600
)
#expect(result.seekTime == 50)
#expect(result.hitBoundary == false)
}
@Test("Forward seek past end is clamped")
func forwardPastEnd() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 550,
seekDelta: 100,
duration: 600
)
#expect(result.seekTime == 600)
#expect(result.hitBoundary == true)
}
@Test("Backward seek past start is clamped")
func backwardPastStart() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 30,
seekDelta: -50,
duration: 600
)
#expect(result.seekTime == 0)
#expect(result.hitBoundary == true)
}
@Test("Exactly at boundary does not report hit")
func exactlyAtBoundary() {
// Seek to exactly the end
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 500,
seekDelta: 100,
duration: 600
)
#expect(result.seekTime == 600)
#expect(result.hitBoundary == false)
}
@Test("Zero duration clamps to zero")
func zeroDuration() {
let result = SeekGestureCalculator.clampSeekTime(
currentTime: 0,
seekDelta: 100,
duration: 0
)
#expect(result.seekTime == 0)
#expect(result.hitBoundary == true)
}
}
}
// MARK: - GesturesSettings Integration
@Suite("GesturesSettings Integration")
struct GesturesSettingsIntegrationTests {
@Test("hasActiveGestures includes seek gesture")
func hasActiveGesturesIncludesSeek() {
// Only seek enabled
let seekOnly = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: false),
seekGesture: SeekGestureSettings(isEnabled: true),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(seekOnly.hasActiveGestures == true)
#expect(seekOnly.isSeekGestureActive == true)
#expect(seekOnly.areTapGesturesActive == false)
// Only tap enabled
let tapOnly = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(tapOnly.hasActiveGestures == true)
#expect(tapOnly.isSeekGestureActive == false)
#expect(tapOnly.areTapGesturesActive == true)
// Both enabled
let both = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true),
seekGesture: SeekGestureSettings(isEnabled: true),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(both.hasActiveGestures == true)
// Neither enabled (all disabled including panscan)
let neither = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: false),
seekGesture: SeekGestureSettings(isEnabled: false),
panscanGesture: PanscanGestureSettings(isEnabled: false)
)
#expect(neither.hasActiveGestures == false)
}
@Test("GesturesSettings serialization with seek gesture")
func serializationWithSeekGesture() throws {
let original = GesturesSettings(
tapGestures: TapGesturesSettings(isEnabled: true, layout: .quadrants),
seekGesture: SeekGestureSettings(isEnabled: true, sensitivity: .high)
)
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(GesturesSettings.self, from: encoded)
#expect(decoded.tapGestures.isEnabled == original.tapGestures.isEnabled)
#expect(decoded.tapGestures.layout == original.tapGestures.layout)
#expect(decoded.seekGesture.isEnabled == original.seekGesture.isEnabled)
#expect(decoded.seekGesture.sensitivity == original.seekGesture.sensitivity)
}
}
}