First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
/// Tests for MpvBackend to prevent regressions
///
/// These tests are designed to catch common issues like:
/// - Tokio runtime panics when spawning async tasks from std::thread
/// - Position update thread failures
/// - Event emission issues
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
/// Test that simulates the position update thread spawning async tasks
/// without a Tokio runtime (the bug we just fixed)
#[test]
fn test_position_thread_handles_missing_tokio_runtime() {
use std::sync::atomic::{AtomicBool, Ordering};
let success = Arc::new(AtomicBool::new(false));
let success_clone = success.clone();
// Spawn a regular thread (no Tokio runtime)
let handle = std::thread::spawn(move || {
// This simulates what the position update thread does
// It should handle the case where there's no Tokio runtime
// Try to get the current Tokio runtime handle
if let Ok(handle) = tokio::runtime::Handle::try_current() {
// We have a runtime, use it
handle.spawn(async move {
// Async work here
});
} else {
// No runtime, spawn a new thread with its own runtime
// This is the fix we applied
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
// Async work here
success_clone.store(true, Ordering::SeqCst);
});
});
}
});
handle.join().unwrap();
// Give the spawned thread time to complete
std::thread::sleep(std::time::Duration::from_millis(100));
assert!(
success.load(Ordering::SeqCst),
"Should successfully execute async code from std::thread without panicking"
);
}
/// Test that the Tokio runtime fallback pattern works correctly
#[test]
fn test_tokio_runtime_fallback_pattern() {
let counter = Arc::new(Mutex::new(0));
let counter_clone = counter.clone();
// Spawn from a regular thread (no runtime)
let handle = std::thread::spawn(move || {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
// Has runtime (shouldn't happen in this test)
handle.spawn(async move {
*counter_clone.lock().unwrap() += 1;
});
} else {
// No runtime - use fallback (should happen in this test)
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
*counter_clone.lock().unwrap() += 1;
});
});
}
});
handle.join().unwrap();
// Wait for async task to complete
std::thread::sleep(std::time::Duration::from_millis(100));
let count = *counter.lock().unwrap();
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
}
/// Test that position update logic works in a thread
#[test]
fn test_position_update_in_thread() {
use std::time::Duration;
let positions = Arc::new(Mutex::new(Vec::new()));
let positions_clone = positions.clone();
// Simulate the position update thread
let handle = std::thread::spawn(move || {
for i in 0..5 {
std::thread::sleep(Duration::from_millis(10));
// Simulate getting position from player
let position = i as f64 * 0.25;
// Store position (simulating event emission)
positions_clone.lock().unwrap().push(position);
}
});
handle.join().unwrap();
let recorded_positions = positions.lock().unwrap();
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
// Verify positions are increasing
for (i, pos) in recorded_positions.iter().enumerate() {
let expected = i as f64 * 0.25;
assert!(
(*pos - expected).abs() < 0.001,
"Position {} should be close to {}",
pos,
expected
);
}
}
/// Test async progress reporting pattern
#[tokio::test]
async fn test_progress_reporting_with_tokio_mutex() {
use crate::playback_reporting::{EventThrottler, PlaybackReporter};
// Create mock reporter (None for this test)
let reporter = Arc::new(TokioMutex::new(None::<PlaybackReporter>));
let throttler = Arc::new(EventThrottler::new());
// Simulate progress reporting
let item_id = "test_item_123".to_string();
// This should not panic even though reporter is None
let reporter_guard = reporter.lock().await;
if let Some(_reporter_instance) = reporter_guard.as_ref() {
// Would report here if reporter was configured
} else {
// Reporter not configured - this is OK
}
drop(reporter_guard);
// Verify throttler works
assert!(
throttler.should_report(&item_id),
"First report should be allowed"
);
throttler.mark_reported(&item_id);
// Immediate second report should be throttled
// (EventThrottler has internal logic for this)
}
/// Test that position updates are emitted even when paused (for scrubbing)
/// This is critical for UI responsiveness when seeking while paused
#[test]
fn test_position_updates_while_paused() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let update_count = Arc::new(AtomicUsize::new(0));
let update_count_clone = update_count.clone();
// Simulate a position update thread that runs regardless of pause state
let handle = std::thread::spawn(move || {
// Simulate 5 position updates
for _ in 0..5 {
std::thread::sleep(Duration::from_millis(50));
// In the real implementation, we check position from MPV
// and emit PositionUpdate events even when paused
// This simulates that behavior:
let _is_paused = true; // Simulating paused state
// Key: We DON'T skip the update when paused
// This allows scrubbing to work
update_count_clone.fetch_add(1, Ordering::SeqCst);
}
});
handle.join().unwrap();
let final_count = update_count.load(Ordering::SeqCst);
assert_eq!(
final_count, 5,
"Position updates should be emitted even when paused (got {} updates)",
final_count
);
}
/// Test that progress reporting is skipped when paused
/// Progress reporting to the server should only happen during active playback
#[test]
fn test_progress_reporting_skipped_when_paused() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
let report_count = Arc::new(AtomicUsize::new(0));
let report_count_clone = report_count.clone();
// Simulate the progress reporting logic
let handle = std::thread::spawn(move || {
// Simulate 5 update cycles
for i in 0..5 {
std::thread::sleep(Duration::from_millis(50));
// Position updates are emitted (tested separately)
// But progress reporting depends on pause state
let is_paused = i % 2 == 0; // Alternate between paused and playing
// Key: Only report when NOT paused
if !is_paused {
report_count_clone.fetch_add(1, Ordering::SeqCst);
}
}
});
handle.join().unwrap();
let final_count = report_count.load(Ordering::SeqCst);
assert_eq!(
final_count, 2,
"Progress reporting should only happen when not paused (got {} reports)",
final_count
);
}
/// Test that position updates are suppressed briefly after a seek
/// This prevents "jumping to zero" visual glitches during seek operations
#[test]
fn test_position_updates_suppressed_after_seek() {
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let last_seek_time = Arc::new(AtomicU64::new(0));
let last_seek_time_clone = last_seek_time.clone();
let update_count = Arc::new(AtomicUsize::new(0));
let update_count_clone = update_count.clone();
// Simulate a seek happening
let seek_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
last_seek_time.store(seek_time, Ordering::Relaxed);
// Simulate position update thread
let handle = std::thread::spawn(move || {
// Try 2 position updates at 50ms intervals (well within the 150ms window)
for _ in 0..2 {
std::thread::sleep(Duration::from_millis(50));
// Check if we should suppress updates (within 150ms of seek)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let last_seek = last_seek_time_clone.load(Ordering::Relaxed);
let time_since_seek = now.saturating_sub(last_seek);
if time_since_seek < 150 {
// Suppress update (don't increment counter)
continue;
}
// Emit update
update_count_clone.fetch_add(1, Ordering::SeqCst);
}
});
handle.join().unwrap();
let final_count = update_count.load(Ordering::SeqCst);
// With 50ms intervals and 150ms suppression window, updates at 50ms and 100ms
// should both be suppressed
assert_eq!(
final_count, 0,
"Position updates should be suppressed within 150ms of seek (got {} updates)",
final_count
);
}
/// Test that position updates resume after seek suppression window
#[test]
fn test_position_updates_resume_after_seek_window() {
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let last_seek_time = Arc::new(AtomicU64::new(0));
let last_seek_time_clone = last_seek_time.clone();
let update_count = Arc::new(AtomicUsize::new(0));
let update_count_clone = update_count.clone();
// Simulate a seek that happened 200ms ago (past the suppression window)
let seek_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
- 200; // 200ms ago
last_seek_time.store(seek_time, Ordering::Relaxed);
// Simulate position update thread
let handle = std::thread::spawn(move || {
// Try 3 position updates
for _ in 0..3 {
std::thread::sleep(Duration::from_millis(10));
// Check if we should suppress updates (within 150ms of seek)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let last_seek = last_seek_time_clone.load(Ordering::Relaxed);
let time_since_seek = now.saturating_sub(last_seek);
if time_since_seek < 150 {
continue; // Should not happen in this test
}
// Emit update
update_count_clone.fetch_add(1, Ordering::SeqCst);
}
});
handle.join().unwrap();
let final_count = update_count.load(Ordering::SeqCst);
assert_eq!(
final_count, 3,
"Position updates should resume after seek suppression window (got {} updates)",
final_count
);
}
}