experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

state: proptest covers SetAutoplay/SetSkipThreshold + invariants

karitham (Jun 1, 2026, 4:38 PM +0200) 79083f9f 14c136cd

+92 -2
+92 -2
src/state.rs
··· 612 612 } 613 613 614 614 /// Set the skip-vote threshold as a percentage of room size. Out of 615 - /// range values are clamped or no-op (0 means any single vote skips; 616 - /// 100 means unanimous). Persists + publishes on change. 615 + /// range values are clamped to 0..=100 (0 means any single vote 616 + /// skips; 100 means unanimous). Persists + publishes on change. 617 617 fn handle_set_skip_threshold(&mut self, percent: u8) -> Vec<Effect> { 618 + let percent = percent.min(100); 618 619 if self.skip_threshold == percent { 619 620 return Vec::new(); 620 621 } ··· 2247 2248 /// machine no-ops, mirroring the real-world "clicked X on a track 2248 2249 /// already removed" case. 2249 2250 RemoveQueueItem(u64), 2251 + /// Toggle autoplay. The no-op guard for empty playlist + re-enable 2252 + /// is checked in apply_ops. 2253 + SetAutoplay(bool), 2254 + /// Set the skip-vote threshold. The no-op guard for unchanged value 2255 + /// is checked in apply_ops. 2256 + SetSkipThreshold(u8), 2250 2257 } 2251 2258 2252 2259 fn op_strategy() -> impl Strategy<Value = Vec<Op>> { ··· 2264 2271 3 => (0u8..16, 0u8..16).prop_map(|(from, to)| Op::MoveQueueItem { from, to }), 2265 2272 2 => (0u64..1000).prop_map(|seed| Op::ShuffleQueue { seed }), 2266 2273 3 => (0u64..12).prop_map(Op::RemoveQueueItem), 2274 + 2 => any::<bool>().prop_map(Op::SetAutoplay), 2275 + 2 => any::<u8>().prop_map(Op::SetSkipThreshold), 2267 2276 ]; 2268 2277 proptest::collection::vec(op, 0..50) 2269 2278 } ··· 2474 2483 } 2475 2484 effects 2476 2485 } 2486 + Op::SetAutoplay(enabled) => { 2487 + let prev = state.autoplay_enabled; 2488 + let effects = state.transition(&Event::SetAutoplay { enabled: *enabled }, 0); 2489 + // No-op guard: enabling on empty playlist, or matching 2490 + // the current value, must return no effects and not 2491 + // mutate state. 2492 + let is_noop = (*enabled && state.playlist.is_empty()) || *enabled == prev; 2493 + if is_noop { 2494 + assert!( 2495 + effects.is_empty(), 2496 + "SetAutoplay no-op must return no effects" 2497 + ); 2498 + assert_eq!( 2499 + state.autoplay_enabled, prev, 2500 + "SetAutoplay no-op must not mutate state" 2501 + ); 2502 + } else { 2503 + // Real change: state must reflect it, and the 2504 + // PersistRoomSettings effect (if any) must carry 2505 + // the new value. 2506 + assert_eq!(state.autoplay_enabled, *enabled); 2507 + assert!( 2508 + effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)), 2509 + "SetAutoplay change must publish: {effects:?}" 2510 + ); 2511 + } 2512 + effects 2513 + } 2514 + Op::SetSkipThreshold(percent) => { 2515 + let prev = state.skip_threshold; 2516 + // State machine clamps the input to 0..=100. 2517 + let clamped = (*percent).min(100u8); 2518 + let effects = 2519 + state.transition(&Event::SetSkipThreshold { percent: *percent }, 0); 2520 + if clamped == prev { 2521 + assert!( 2522 + effects.is_empty(), 2523 + "SetSkipThreshold no-op (after clamp) must return no effects" 2524 + ); 2525 + assert_eq!(state.skip_threshold, prev); 2526 + } else { 2527 + assert_eq!(state.skip_threshold, clamped); 2528 + assert!( 2529 + effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)), 2530 + "SetSkipThreshold change must publish: {effects:?}" 2531 + ); 2532 + } 2533 + effects 2534 + } 2477 2535 }; 2478 2536 2479 2537 check_invariants(&state, &effects, &queued_ids, &mut history_snapshot); ··· 2499 2557 Event::MoveQueueItem { from: 0, to: 0 }, 2500 2558 Event::ShuffleQueue { seed: 0 }, 2501 2559 Event::RemoveQueueItem { track_id: tid(0) }, 2560 + Event::SetAutoplay { enabled: true }, 2561 + Event::SetAutoplay { enabled: false }, 2562 + Event::SetSkipThreshold { percent: 50 }, 2563 + Event::SetSkipThreshold { percent: 0 }, 2564 + Event::SetSkipThreshold { percent: 100 }, 2502 2565 ] { 2503 2566 let mut clone = state.clone(); 2504 2567 let e1 = state.transition(event, 0); ··· 2738 2801 } 2739 2802 } 2740 2803 } 2804 + 2805 + // 15. PersistRoomSettings effect payload must match the 2806 + // post-transition state. The actor uses these to overwrite 2807 + // DB columns; a stale payload would diverge state from 2808 + // what's persisted. 2809 + for e in effects { 2810 + if let Effect::PersistRoomSettings { 2811 + autoplay_enabled, 2812 + skip_threshold, 2813 + } = e 2814 + { 2815 + assert_eq!( 2816 + *autoplay_enabled, state.autoplay_enabled, 2817 + "PersistRoomSettings.autoplay_enabled stale" 2818 + ); 2819 + assert_eq!( 2820 + *skip_threshold, state.skip_threshold, 2821 + "PersistRoomSettings.skip_threshold stale" 2822 + ); 2823 + } 2824 + } 2825 + 2826 + // 16. skip_threshold stays in 0..=100 across the run. State 2827 + // is `u8` so the type guarantees this, but a regression 2828 + // that wraps/clips would slip past the type system — pin 2829 + // it here. 2830 + assert!(state.skip_threshold <= 100); 2741 2831 } 2742 2832 2743 2833 fn is_persist_effect(e: &Effect) -> bool {