import XCTest @testable import HaptikaSignal final class PPGSyntheticSignalTests: XCTestCase { private let processorConfiguration = PPGProcessingConfiguration( minimumAnalysisDurationSeconds: 45, filterTapCount: 61 ) func testPublicationThresholdsRemainOrderedByScientificDemand() { XCTAssertEqual(PPGPublicationThresholds.bpmQualityScore, 0.45) XCTAssertEqual(PPGPublicationThresholds.potentialIndexQualityScore, 0.65) XCTAssertEqual(PPGPublicationThresholds.prvQualityScore, 0.80) XCTAssertLessThan( PPGPublicationThresholds.bpmQualityScore, PPGPublicationThresholds.potentialIndexQualityScore ) XCTAssertLessThan( PPGPublicationThresholds.potentialIndexQualityScore, PPGPublicationThresholds.prvQualityScore ) } func testGeneratorIsDeterministicAndAppliesAllConfiguredArtifacts() throws { let configuration = PPGSyntheticSignalConfiguration( durationSeconds: 10, startingBPM: 72, intervalVariationMilliseconds: [-20, 20], noiseAmplitude: 1.5, driftAmplitude: 0.05, movementWindows: [.init(startSeconds: 2, endSeconds: 3, intensity: 0.8)], saturationWindows: [.init(startSeconds: 5, endSeconds: 6)], dropEveryNthFrame: 10, missingBeatIndices: [3], falsePeakTimesSeconds: [7.25], seed: 42 ) let first = try PPGSyntheticSignalGenerator.generate(configuration: configuration) let second = try PPGSyntheticSignalGenerator.generate(configuration: configuration) XCTAssertEqual(first, second) XCTAssertLessThan(first.samples.count, 301) XCTAssertTrue(first.samples.contains { $0.motionArtifactScore >= 0.8 }) XCTAssertTrue(first.samples.contains { $0.saturationRatio == 1 }) XCTAssertEqual(first.nominalIntervalsMilliseconds[0], 60_000 / 72 - 20, accuracy: 0.001) XCTAssertEqual(first.nominalIntervalsMilliseconds[1], 60_000 / 72 + 20, accuracy: 0.001) } func testEndToEndCleanSignalsAt60_75And120BPM() throws { let processor = try PPGSignalProcessor(configuration: processorConfiguration) for expectedBPM in [60.0, 75.0, 120.0] { let fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration( durationSeconds: 60, startingBPM: expectedBPM, noiseAmplitude: 0.25, driftAmplitude: 0.04, seed: UInt64(expectedBPM) ) ) let result = try processor.analyze(samples: fixture.samples) XCTAssertEqual(result.status, .valid, "Expected a valid \(expectedBPM) BPM fixture") XCTAssertEqual(result.bpm.bpm ?? 0, expectedBPM, accuracy: 3) XCTAssertFalse(result.quality.issues.contains(.nonFiniteInput)) } } func testModerateNoiseDriftAndDroppedFramesRemainAnalyzable() throws { let fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration( durationSeconds: 60, startingBPM: 78, noiseAmplitude: 1.2, driftAmplitude: 0.10, dropEveryNthFrame: 20, seed: 77 ) ) let result = try PPGSignalProcessor(configuration: processorConfiguration) .analyze(samples: fixture.samples) XCTAssertEqual(result.bpm.bpm ?? 0, 78, accuracy: 4) XCTAssertGreaterThan(result.timebase.droppedFrameRatio, 0) } func testMovementAndSaturationAreReportedInsteadOfProducingSilentPRV() throws { let fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration( durationSeconds: 60, startingBPM: 72, noiseAmplitude: 0.5, movementWindows: [.init(startSeconds: 10, endSeconds: 45, intensity: 1)], saturationWindows: [.init(startSeconds: 8, endSeconds: 52, intensity: 1)], seed: 91 ) ) let result = try PPGSignalProcessor(configuration: processorConfiguration) .analyze(samples: fixture.samples) XCTAssertTrue(result.quality.issues.contains(.motionArtifacts)) XCTAssertTrue(result.quality.issues.contains(.excessiveSaturation)) XCTAssertFalse(result.prv.reliable) } func testMissingAndFalsePeaksDoNotCreateNonFiniteMetrics() throws { let fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration( durationSeconds: 60, startingBPM: 75, noiseAmplitude: 0.4, missingBeatIndices: [8, 22, 41], falsePeakTimesSeconds: [12.30, 31.15, 46.40], seed: 121 ) ) let result = try PPGSignalProcessor(configuration: processorConfiguration) .analyze(samples: fixture.samples) XCTAssertTrue(result.bpm.confidence.isFinite) XCTAssertTrue(result.quality.score.isFinite) XCTAssertTrue(result.intervals.allSatisfy { $0.durationMilliseconds.isFinite }) XCTAssertGreaterThan(result.bpm.rejectedPeakCount + result.prv.rejectedIntervalCount, 0) } func testProgressiveBPMChangeStaysInsideGeneratedRange() throws { let fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration( durationSeconds: 60, startingBPM: 60, endingBPM: 90, noiseAmplitude: 0.2, seed: 3 ) ) let result = try PPGSignalProcessor(configuration: processorConfiguration) .analyze(samples: fixture.samples) XCTAssertNotNil(result.bpm.bpm) XCTAssertTrue((60...90).contains(result.bpm.bpm ?? 0)) XCTAssertGreaterThan(fixture.nominalIntervalsMilliseconds.first ?? 0, 950) XCTAssertLessThan(fixture.nominalIntervalsMilliseconds.last ?? 1_000, 700) } func testKnownIntervalVariabilityProducesExpectedRMSSD() throws { let offsets = [-30.0, 30.0] let fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration( durationSeconds: 60, startingBPM: 75, intervalVariationMilliseconds: offsets ) ) let metrics = try PPGPRVCalculator.calculate( intervalsMilliseconds: Array(fixture.nominalIntervalsMilliseconds.prefix(60)), configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 20) ) XCTAssertEqual(metrics.estimatedRmssdMs ?? 0, 60, accuracy: 0.001) XCTAssertTrue(metrics.reliable) } func testNaNAndInfinityFramesAreDiscardedExhaustively() throws { var fixture = try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration(durationSeconds: 60, startingBPM: 72) ) fixture.samples.insert( PPGFrameSample(timestampSeconds: .nan, red: 1, green: 1, luminance: 1), at: 20 ) fixture.samples.insert( PPGFrameSample(timestampSeconds: 2, red: .infinity, green: 1, luminance: 1), at: 40 ) fixture.samples.insert( PPGFrameSample(timestampSeconds: 3, red: 1, green: -.infinity, luminance: 1), at: 60 ) fixture.samples.insert( PPGFrameSample(timestampSeconds: 4, red: 1, green: 1, luminance: .nan), at: 80 ) let result = try PPGSignalProcessor(configuration: processorConfiguration) .analyze(samples: fixture.samples) XCTAssertEqual(result.timebase.discardedSampleCount, 4) XCTAssertTrue(result.quality.issues.contains(.nonFiniteInput)) XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3) } func testGeneratorRejectsUnsafeConfiguration() { XCTAssertThrowsError( try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration(sampleRateHz: .nan) ) ) XCTAssertThrowsError( try PPGSyntheticSignalGenerator.generate( configuration: PPGSyntheticSignalConfiguration(dropEveryNthFrame: 1) ) ) } }