This commit is contained in:
Jin
2026-05-14 17:50:06 +08:00
parent 63e29be346
commit fdde6c51ce
25 changed files with 2970 additions and 295 deletions
@@ -0,0 +1,133 @@
#if os(iOS)
import Testing
import CoreGraphics
@testable import TVBox
/// Property 7: Channel overlay group width is proportional
/// For any screen width w > 0, computeGroupWidth(screenWidth: w) returns a value in [w * 0.30, w * 0.35]
///
/// **Validates: Requirements 8.3, 8.4**
@Suite("Property 7: Channel overlay group width is proportional")
struct ChannelOverlayGroupWidthPropertyTests {
// MARK: - Parameterized tests with representative screen widths
/// Common iOS device screen widths and edge cases
static let screenWidths: [CGFloat] = [
// Small values
1.0, 10.0, 50.0,
// iPhone SE / small phones
320.0, 375.0,
// iPhone standard
390.0, 393.0,
// iPhone Plus / Max
414.0, 428.0, 430.0,
// iPad
744.0, 768.0, 810.0, 834.0, 1024.0, 1194.0,
// Large values
1366.0, 2048.0, 5000.0
]
@Test("computeGroupWidth returns value in [w*0.30, w*0.35] for common screen widths",
arguments: screenWidths)
func groupWidthInProportionalRange(screenWidth: CGFloat) {
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"computeGroupWidth(\(screenWidth)) = \(result) should be >= \(lowerBound) (w * 0.30)")
#expect(result <= upperBound,
"computeGroupWidth(\(screenWidth)) = \(result) should be <= \(upperBound) (w * 0.35)")
}
// MARK: - Randomized property test
@Test("computeGroupWidth satisfies proportional invariant for random positive widths")
func groupWidthProportionalRandomized() {
for _ in 0..<1000 {
// Generate random positive screen widths across different magnitudes
let screenWidth = CGFloat.random(in: 0.001...10000.0)
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"Random width \(screenWidth): result \(result) should be >= \(lowerBound)")
#expect(result <= upperBound,
"Random width \(screenWidth): result \(result) should be <= \(upperBound)")
}
}
// MARK: - Proportionality: result scales linearly with screen width
@Test("computeGroupWidth scales linearly with screen width")
func groupWidthScalesLinearly() {
let widths: [CGFloat] = [100.0, 200.0, 400.0, 800.0]
for i in 0..<(widths.count - 1) {
let w1 = widths[i]
let w2 = widths[i + 1]
let result1 = ChannelOverlayView.computeGroupWidth(screenWidth: w1)
let result2 = ChannelOverlayView.computeGroupWidth(screenWidth: w2)
let ratio = w2 / w1
let resultRatio = result2 / result1
// The ratio of results should equal the ratio of inputs (linear scaling)
#expect(abs(resultRatio - ratio) < 0.001,
"Group width should scale linearly: ratio of widths = \(ratio), ratio of results = \(resultRatio)")
}
}
// MARK: - Edge case: very small positive widths
@Test("computeGroupWidth handles very small positive widths", arguments: [
CGFloat(0.001),
CGFloat(0.01),
CGFloat(0.1),
CGFloat(0.5),
])
func groupWidthSmallPositiveWidths(screenWidth: CGFloat) {
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"Small width \(screenWidth): result \(result) should be >= \(lowerBound)")
#expect(result <= upperBound,
"Small width \(screenWidth): result \(result) should be <= \(upperBound)")
}
// MARK: - Edge case: very large widths
@Test("computeGroupWidth handles very large widths", arguments: [
CGFloat(10000.0),
CGFloat(50000.0),
CGFloat(100000.0),
])
func groupWidthLargeWidths(screenWidth: CGFloat) {
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
let lowerBound = screenWidth * 0.30
let upperBound = screenWidth * 0.35
#expect(result >= lowerBound,
"Large width \(screenWidth): result \(result) should be >= \(lowerBound)")
#expect(result <= upperBound,
"Large width \(screenWidth): result \(result) should be <= \(upperBound)")
}
// MARK: - Result is always positive for positive input
@Test("computeGroupWidth always returns positive value for positive input")
func groupWidthAlwaysPositive() {
for _ in 0..<500 {
let screenWidth = CGFloat.random(in: 0.001...10000.0)
let result = ChannelOverlayView.computeGroupWidth(screenWidth: screenWidth)
#expect(result > 0, "computeGroupWidth(\(screenWidth)) = \(result) should be > 0")
}
}
}
#endif
@@ -0,0 +1,271 @@
#if os(iOS)
import Testing
import CoreGraphics
@testable import TVBox
/// Property-based tests for PlayerGestureLayer pure computation functions.
/// These tests validate correctness properties across a wide range of inputs.
// MARK: - Property 3: Vertical gesture direction mapping preserves monotonicity
/// **Validates: Requirements 2.2, 3.2**
///
/// For any vertical translation `dy`, the computed adjustment delta SHALL be positive when
/// `dy < 0` (upward swipe) and negative when `dy > 0` (downward swipe), ensuring that
/// upward gestures always increase the controlled value and downward gestures always decrease it.
@Suite("Property 3: Vertical gesture direction mapping preserves monotonicity")
struct VerticalGestureMonotonicityPropertyTests {
// Use a container width and threshold that ensure vertical gesture classification
static let containerWidth: CGFloat = 400.0
static let threshold: CGFloat = 10.0
// MARK: - Parameterized tests with representative dy values
/// Negative dy values (upward swipes) that exceed the threshold
static let negativeDyValues: [CGFloat] = [
-11.0, -20.0, -50.0, -100.0, -300.0, -500.0, -1000.0, -1500.0
]
/// Positive dy values (downward swipes) that exceed the threshold
static let positiveDyValues: [CGFloat] = [
11.0, 20.0, 50.0, 100.0, 300.0, 500.0, 1000.0
]
/// Start positions covering both left half (brightness) and right half (volume)
static let startPositions: [CGFloat] = [0.0, 50.0, 100.0, 199.0, 200.0, 250.0, 350.0, 399.0]
@Test("Upward swipe (negative dy) produces positive delta",
arguments: negativeDyValues, startPositions)
func upwardSwipeProducesPositiveDelta(dy: CGFloat, startX: CGFloat) {
// Use dx = 0 to ensure vertical classification (|dy| > |dx|)
let translation = CGSize(width: 0, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
switch result {
case .adjustingBrightness(let delta):
#expect(delta > 0,
"Upward swipe (dy=\(dy)) on left side should produce positive delta, got \(delta)")
case .adjustingVolume(let delta):
#expect(delta > 0,
"Upward swipe (dy=\(dy)) on right side should produce positive delta, got \(delta)")
default:
Issue.record("Expected brightness or volume adjustment for dy=\(dy), startX=\(startX), got \(result)")
}
}
@Test("Downward swipe (positive dy) produces negative delta",
arguments: positiveDyValues, startPositions)
func downwardSwipeProducesNegativeDelta(dy: CGFloat, startX: CGFloat) {
// Use dx = 0 to ensure vertical classification (|dy| > |dx|)
let translation = CGSize(width: 0, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
switch result {
case .adjustingBrightness(let delta):
#expect(delta < 0,
"Downward swipe (dy=\(dy)) on left side should produce negative delta, got \(delta)")
case .adjustingVolume(let delta):
#expect(delta < 0,
"Downward swipe (dy=\(dy)) on right side should produce negative delta, got \(delta)")
default:
Issue.record("Expected brightness or volume adjustment for dy=\(dy), startX=\(startX), got \(result)")
}
}
// MARK: - Randomized property test
@Test("Monotonicity holds for random vertical translations")
func monotonicityRandomized() {
for _ in 0..<1000 {
let startX = CGFloat.random(in: 0.0..<Self.containerWidth)
// Generate dy that exceeds threshold (either positive or negative)
let dyMagnitude = CGFloat.random(in: (Self.threshold + 1)...1000.0)
let isUpward = Bool.random()
let dy = isUpward ? -dyMagnitude : dyMagnitude
// Use small dx to ensure vertical classification
let dx = CGFloat.random(in: -Self.threshold...Self.threshold) * 0.5
let translation = CGSize(width: dx, height: dy)
let result = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
switch result {
case .adjustingBrightness(let delta), .adjustingVolume(let delta):
if dy < 0 {
#expect(delta > 0,
"Upward swipe (dy=\(dy)) should produce positive delta, got \(delta)")
} else {
#expect(delta < 0,
"Downward swipe (dy=\(dy)) should produce negative delta, got \(delta)")
}
case .seeking:
// If |dx| > |dy|, it classifies as seeking - this is acceptable
// since we're testing the vertical case
break
case .none:
// Below threshold - acceptable for edge cases
break
}
}
}
// MARK: - Delta magnitude proportionality
@Test("Larger vertical displacement produces larger absolute delta")
func deltaMagnitudeProportional() {
let startX: CGFloat = 100.0 // Left half for brightness
let smallDy: CGFloat = -20.0
let largeDy: CGFloat = -200.0
let smallTranslation = CGSize(width: 0, height: smallDy)
let largeTranslation = CGSize(width: 0, height: largeDy)
let smallResult = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: smallTranslation,
threshold: Self.threshold
)
let largeResult = PlayerGestureLayer.classifyGesture(
startX: startX,
containerWidth: Self.containerWidth,
translation: largeTranslation,
threshold: Self.threshold
)
if case .adjustingBrightness(let smallDelta) = smallResult,
case .adjustingBrightness(let largeDelta) = largeResult {
#expect(largeDelta > smallDelta,
"Larger upward swipe should produce larger positive delta: small=\(smallDelta), large=\(largeDelta)")
} else {
Issue.record("Expected brightness adjustments for both translations")
}
}
// MARK: - Zero dy produces no vertical gesture
@Test("Zero dy does not produce vertical adjustment")
func zeroDyNoVerticalAdjustment() {
let translation = CGSize(width: 0, height: 0)
let result = PlayerGestureLayer.classifyGesture(
startX: 100.0,
containerWidth: Self.containerWidth,
translation: translation,
threshold: Self.threshold
)
#expect(result == .none,
"Zero translation should produce .none, got \(result)")
}
}
// MARK: - Property 4: Adjustment value clamping invariant
/// **Validates: Requirements 2.4, 3.4**
///
/// For any currentValue in [0, 1] and any delta (positive, negative, very large, very small),
/// the result of `clampAdjustment(currentValue:delta:)` must always be in [0.0, 1.0].
@Suite("Property 4: Adjustment value clamping invariant")
struct AdjustmentClampingPropertyTests {
// MARK: - Parameterized boundary values
static let boundaryCurrentValues: [CGFloat] = [0.0, 0.001, 0.25, 0.5, 0.75, 0.999, 1.0]
static let boundaryDeltas: [CGFloat] = [
-1000.0, -100.0, -10.0, -1.0, -0.5, -0.001,
0.0,
0.001, 0.5, 1.0, 10.0, 100.0, 1000.0
]
@Test("Clamped result is always within [0, 1] for boundary values",
arguments: boundaryCurrentValues, boundaryDeltas)
func clampedResultInRange(currentValue: CGFloat, delta: CGFloat) {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0, "Result \(result) should be >= 0.0 for currentValue=\(currentValue), delta=\(delta)")
#expect(result <= 1.0, "Result \(result) should be <= 1.0 for currentValue=\(currentValue), delta=\(delta)")
}
// MARK: - Randomized property test
@Test("Clamped result is always within [0, 1] for random inputs")
func clampedResultInRangeRandomized() {
// Generate many random test cases to approximate property-based testing
for _ in 0..<1000 {
let currentValue = CGFloat.random(in: 0.0...1.0)
let delta = CGFloat.random(in: -1000.0...1000.0)
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0, "Result \(result) should be >= 0.0 for currentValue=\(currentValue), delta=\(delta)")
#expect(result <= 1.0, "Result \(result) should be <= 1.0 for currentValue=\(currentValue), delta=\(delta)")
}
}
// MARK: - Edge cases with extreme deltas
@Test("Clamped result handles extreme positive deltas")
func extremePositiveDeltas() {
let extremeDeltas: [CGFloat] = [CGFloat.greatestFiniteMagnitude / 2, 1e10, 1e6]
for delta in extremeDeltas {
for currentValue in [0.0, 0.5, 1.0] as [CGFloat] {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0 && result <= 1.0,
"Result \(result) out of range for currentValue=\(currentValue), delta=\(delta)")
}
}
}
@Test("Clamped result handles extreme negative deltas")
func extremeNegativeDeltas() {
let extremeDeltas: [CGFloat] = [-CGFloat.greatestFiniteMagnitude / 2, -1e10, -1e6]
for delta in extremeDeltas {
for currentValue in [0.0, 0.5, 1.0] as [CGFloat] {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: delta)
#expect(result >= 0.0 && result <= 1.0,
"Result \(result) out of range for currentValue=\(currentValue), delta=\(delta)")
}
}
}
// MARK: - Zero delta preserves value
@Test("Zero delta preserves the current value",
arguments: boundaryCurrentValues)
func zeroDeltaPreservesValue(currentValue: CGFloat) {
let result = PlayerGestureLayer.clampAdjustment(currentValue: currentValue, delta: 0.0)
#expect(result == currentValue,
"Zero delta should preserve value, got \(result) instead of \(currentValue)")
}
// MARK: - Idempotence at boundaries
@Test("Result at lower bound stays at lower bound with negative delta")
func lowerBoundIdempotence() {
let result = PlayerGestureLayer.clampAdjustment(currentValue: 0.0, delta: -0.5)
#expect(result == 0.0, "Should clamp to 0.0 at lower bound, got \(result)")
}
@Test("Result at upper bound stays at upper bound with positive delta")
func upperBoundIdempotence() {
let result = PlayerGestureLayer.clampAdjustment(currentValue: 1.0, delta: 0.5)
#expect(result == 1.0, "Should clamp to 1.0 at upper bound, got \(result)")
}
}
#endif
@@ -0,0 +1,130 @@
#if os(iOS)
import Testing
import CoreGraphics
@testable import TVBox
/// Property 6: Pinch zoom clamping invariant
/// For any scale s > 0, clampZoom returns a value in [1.0, 3.0]
///
/// **Validates: Requirements 5.2, 5.3**
@Suite("Property 6: Pinch zoom clamping invariant")
struct PlayerGestureLayerZoomClampingTests {
// MARK: - Property Test: Default bounds [1.0, 3.0]
/// Property: For any positive scale, clampZoom with default bounds always returns a value in [1.0, 3.0]
@Test("clampZoom clamps to [1.0, 3.0] for various positive scales", arguments: [
// Boundary values
CGFloat(0.001), // very small positive
CGFloat(0.5), // below min
CGFloat(0.99), // just below min
CGFloat(1.0), // exactly min
CGFloat(1.001), // just above min
CGFloat(1.5), // mid-range
CGFloat(2.0), // mid-range
CGFloat(2.5), // mid-range
CGFloat(2.99), // just below max
CGFloat(3.0), // exactly max
CGFloat(3.001), // just above max
CGFloat(3.5), // above max
CGFloat(5.0), // well above max
CGFloat(10.0), // far above max
CGFloat(100.0), // extreme above max
CGFloat(1000.0), // very extreme
CGFloat(0.0001), // near-zero positive
])
func clampZoomDefaultBounds(scale: CGFloat) {
let result = PlayerGestureLayer.clampZoom(scale: scale)
#expect(result >= 1.0, "clampZoom(\(scale)) = \(result) should be >= 1.0")
#expect(result <= 3.0, "clampZoom(\(scale)) = \(result) should be <= 3.0")
}
// MARK: - Property Test: Custom bounds
/// Property: For any positive scale and valid custom bounds, clampZoom returns a value in [minZoom, maxZoom]
@Test("clampZoom respects custom min/max bounds", arguments: [
(CGFloat(0.5), CGFloat(0.5), CGFloat(2.0)), // scale below custom min
(CGFloat(1.0), CGFloat(0.5), CGFloat(2.0)), // scale within custom range
(CGFloat(2.5), CGFloat(0.5), CGFloat(2.0)), // scale above custom max
(CGFloat(0.1), CGFloat(0.2), CGFloat(5.0)), // scale below wide range min
(CGFloat(3.0), CGFloat(0.2), CGFloat(5.0)), // scale within wide range
(CGFloat(6.0), CGFloat(0.2), CGFloat(5.0)), // scale above wide range max
(CGFloat(2.0), CGFloat(2.0), CGFloat(2.0)), // min equals max
(CGFloat(1.0), CGFloat(2.0), CGFloat(2.0)), // below when min equals max
(CGFloat(3.0), CGFloat(2.0), CGFloat(2.0)), // above when min equals max
(CGFloat(0.01), CGFloat(0.5), CGFloat(10.0)), // very small scale, wide range
(CGFloat(100.0), CGFloat(0.5), CGFloat(10.0)), // very large scale, wide range
])
func clampZoomCustomBounds(scale: CGFloat, minZoom: CGFloat, maxZoom: CGFloat) {
let result = PlayerGestureLayer.clampZoom(scale: scale, minZoom: minZoom, maxZoom: maxZoom)
#expect(result >= minZoom, "clampZoom(\(scale), min: \(minZoom), max: \(maxZoom)) = \(result) should be >= \(minZoom)")
#expect(result <= maxZoom, "clampZoom(\(scale), min: \(minZoom), max: \(maxZoom)) = \(result) should be <= \(maxZoom)")
}
// MARK: - Property Test: Idempotency
/// Property: Clamping an already-clamped value should return the same value
@Test("clampZoom is idempotent - clamping twice gives same result", arguments: [
CGFloat(0.5),
CGFloat(1.0),
CGFloat(2.0),
CGFloat(3.0),
CGFloat(5.0),
])
func clampZoomIdempotent(scale: CGFloat) {
let firstClamp = PlayerGestureLayer.clampZoom(scale: scale)
let secondClamp = PlayerGestureLayer.clampZoom(scale: firstClamp)
#expect(firstClamp == secondClamp, "clampZoom should be idempotent: clampZoom(clampZoom(\(scale))) = \(secondClamp) != clampZoom(\(scale)) = \(firstClamp)")
}
// MARK: - Property Test: Values within range are unchanged
/// Property: If scale is already within [minZoom, maxZoom], it should be returned unchanged
@Test("clampZoom preserves values already within bounds", arguments: [
CGFloat(1.0),
CGFloat(1.5),
CGFloat(2.0),
CGFloat(2.5),
CGFloat(3.0),
])
func clampZoomPreservesValidValues(scale: CGFloat) {
let result = PlayerGestureLayer.clampZoom(scale: scale)
#expect(result == scale, "clampZoom(\(scale)) should return \(scale) unchanged, got \(result)")
}
// MARK: - Randomized Property Test
/// Property: Randomized test with many generated values to approximate property-based testing
@Test("clampZoom satisfies clamping invariant for generated positive scales")
func clampZoomRandomizedProperty() {
// Generate a wide range of positive scale values to test the property
var rng = SystemRandomNumberGenerator()
for _ in 0..<200 {
// Generate random positive CGFloat values across different magnitudes
let magnitude = CGFloat.random(in: 0..<4, using: &rng) // 0 to 4 for exponent
let scale = CGFloat(pow(10.0, Double(magnitude))) * CGFloat.random(in: 0.001..<1.0, using: &rng)
let result = PlayerGestureLayer.clampZoom(scale: scale)
#expect(result >= 1.0, "Random scale \(scale): result \(result) should be >= 1.0")
#expect(result <= 3.0, "Random scale \(scale): result \(result) should be <= 3.0")
}
}
/// Property: Randomized test with custom bounds
@Test("clampZoom satisfies clamping invariant for generated scales with custom bounds")
func clampZoomRandomizedCustomBounds() {
var rng = SystemRandomNumberGenerator()
for _ in 0..<200 {
// Generate random bounds where minZoom <= maxZoom
let minZoom = CGFloat.random(in: 0.1..<5.0, using: &rng)
let maxZoom = minZoom + CGFloat.random(in: 0.0..<10.0, using: &rng)
let scale = CGFloat.random(in: 0.001..<20.0, using: &rng)
let result = PlayerGestureLayer.clampZoom(scale: scale, minZoom: minZoom, maxZoom: maxZoom)
#expect(result >= minZoom, "scale=\(scale), min=\(minZoom), max=\(maxZoom): result \(result) should be >= \(minZoom)")
#expect(result <= maxZoom, "scale=\(scale), min=\(minZoom), max=\(maxZoom): result \(result) should be <= \(maxZoom)")
}
}
}
#endif