use std::collections::{BTreeMap, BTreeSet};
use crate::event::SeekTo;
use super::{EquityPoint, Fill, GreeksAttribution, LoadedBundle, PositionRow};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum PlaybackSpeed {
#[default]
X1,
X2,
X5,
X10,
}
impl PlaybackSpeed {
pub const ALL: [Self; 4] = [Self::X1, Self::X2, Self::X5, Self::X10];
#[must_use]
pub const fn multiplier(self) -> u32 {
match self {
Self::X1 => 1,
Self::X2 => 2,
Self::X5 => 5,
Self::X10 => 10,
}
}
#[must_use]
pub const fn quantum(self) -> i32 {
match self {
Self::X1 => 1,
Self::X2 => 2,
Self::X5 => 5,
Self::X10 => 10,
}
}
#[must_use]
pub const fn faster(self) -> Self {
match self {
Self::X1 => Self::X2,
Self::X2 => Self::X5,
Self::X5 | Self::X10 => Self::X10,
}
}
#[must_use]
pub const fn slower(self) -> Self {
match self {
Self::X10 => Self::X5,
Self::X5 => Self::X2,
Self::X2 | Self::X1 => Self::X1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Playback {
#[default]
Paused,
Playing {
speed: PlaybackSpeed,
},
}
impl Playback {
#[must_use]
pub const fn playing(speed: PlaybackSpeed) -> Self {
Self::Playing { speed }
}
#[must_use]
pub const fn is_playing(self) -> bool {
matches!(self, Self::Playing { .. })
}
#[must_use]
pub const fn speed(self) -> Option<PlaybackSpeed> {
match self {
Self::Paused => None,
Self::Playing { speed } => Some(speed),
}
}
#[must_use]
pub const fn toggled(self, resume_speed: PlaybackSpeed) -> Self {
match self {
Self::Paused => Self::Playing {
speed: resume_speed,
},
Self::Playing { .. } => Self::Paused,
}
}
#[must_use]
pub const fn tick_seek(self) -> Option<SeekTo> {
match self {
Self::Paused => None,
Self::Playing { speed } => Some(SeekTo::StepBy(speed.quantum())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimelineCursor {
end_step: u32,
position: u32,
fills_ix: usize,
equity_ix: usize,
positions_ix: usize,
greeks_ix: usize,
}
impl TimelineCursor {
#[must_use]
pub fn end_step(&self) -> u32 {
self.end_step
}
#[must_use]
pub fn position(&self) -> u32 {
self.position
}
#[must_use]
pub fn new(bundle: &LoadedBundle) -> Self {
let mut cursor = Self {
end_step: end_step_of(bundle),
position: 0,
fills_ix: 0,
equity_ix: 0,
positions_ix: 0,
greeks_ix: 0,
};
cursor.resolve_arbitrary(bundle);
cursor
}
#[must_use]
pub const fn is_at_start(self) -> bool {
self.position == 0
}
#[must_use]
pub const fn is_at_end(self) -> bool {
self.position >= self.end_step
}
pub fn seek(&mut self, to: SeekTo, bundle: &LoadedBundle) {
match to {
SeekTo::Step(target) => {
self.position = target.min(self.end_step);
self.resolve_arbitrary(bundle);
}
SeekTo::StepBy(delta) => {
self.position = self.clamped_step_by(delta);
self.resolve_incremental(bundle);
}
}
}
pub fn advance_playback(&mut self, playback: Playback, bundle: &LoadedBundle) {
if let Some(seek) = playback.tick_seek() {
self.seek(seek, bundle);
}
}
fn clamped_step_by(&self, delta: i32) -> u32 {
let raw = i64::from(self.position) + i64::from(delta);
let clamped = raw.clamp(0, i64::from(self.end_step));
u32::try_from(clamped).unwrap_or(self.end_step)
}
fn resolve_arbitrary(&mut self, bundle: &LoadedBundle) {
let pos = self.position;
self.fills_ix = bundle.fills.partition_point(|f| f.step <= pos);
self.equity_ix = bundle.equity.partition_point(|e| e.step <= pos);
self.positions_ix = bundle.positions.partition_point(|p| p.step <= pos);
self.greeks_ix = bundle.greeks.partition_point(|g| g.step <= pos);
}
fn resolve_incremental(&mut self, bundle: &LoadedBundle) {
let pos = self.position;
self.fills_ix = walk_index(&bundle.fills, self.fills_ix, pos, |f| f.step).0;
self.equity_ix = walk_index(&bundle.equity, self.equity_ix, pos, |e| e.step).0;
self.positions_ix = walk_index(&bundle.positions, self.positions_ix, pos, |p| p.step).0;
self.greeks_ix = walk_index(&bundle.greeks, self.greeks_ix, pos, |g| g.step).0;
}
#[must_use]
pub fn visible_equity<'b>(&self, bundle: &'b LoadedBundle) -> &'b [EquityPoint] {
bundle.equity.get(..self.equity_ix).unwrap_or(&[])
}
#[must_use]
pub fn visible_greeks<'b>(&self, bundle: &'b LoadedBundle) -> &'b [GreeksAttribution] {
bundle.greeks.get(..self.greeks_ix).unwrap_or(&[])
}
#[must_use]
pub fn visible_fills<'b>(&self, bundle: &'b LoadedBundle) -> &'b [Fill] {
bundle.fills.get(..self.fills_ix).unwrap_or(&[])
}
#[must_use]
pub fn visible_positions<'b>(&self, bundle: &'b LoadedBundle) -> &'b [PositionRow] {
bundle.positions.get(..self.positions_ix).unwrap_or(&[])
}
#[must_use]
pub fn head_equity<'b>(&self, bundle: &'b LoadedBundle) -> Option<&'b EquityPoint> {
bundle.equity.get(self.equity_ix.checked_sub(1)?)
}
#[must_use]
pub fn head_greeks<'b>(&self, bundle: &'b LoadedBundle) -> Option<&'b GreeksAttribution> {
bundle.greeks.get(self.greeks_ix.checked_sub(1)?)
}
#[must_use]
pub fn head_fills<'b>(&self, bundle: &'b LoadedBundle) -> &'b [Fill] {
let lo = bundle.fills.partition_point(|f| f.step < self.position);
bundle.fills.get(lo..self.fills_ix).unwrap_or(&[])
}
#[must_use]
pub fn open_positions<'b>(&self, bundle: &'b LoadedBundle) -> Vec<&'b PositionRow> {
let visible = self.visible_positions(bundle);
let mut latest: BTreeMap<u64, &PositionRow> = BTreeMap::new();
let mut closed: BTreeSet<u64> = BTreeSet::new();
for row in visible {
if row.exit_reason.is_some() {
let _ = closed.insert(row.position_id);
}
let _ = latest.insert(row.position_id, row);
}
latest
.into_iter()
.filter(|(id, _)| !closed.contains(id))
.map(|(_, row)| row)
.collect()
}
}
fn end_step_of(bundle: &LoadedBundle) -> u32 {
let last = match bundle.equity.len() {
0 => 0,
n => n - 1,
};
u32::try_from(last).unwrap_or(u32::MAX)
}
fn walk_index<T>(
rows: &[T],
mut ix: usize,
new_pos: u32,
step: impl Fn(&T) -> u32,
) -> (usize, u64) {
let mut moves: u64 = 0;
while let Some(row) = rows.get(ix) {
if step(row) <= new_pos {
ix = ix.checked_add(1).unwrap_or(ix);
moves = moves.checked_add(1).unwrap_or(moves);
} else {
break;
}
}
while ix > 0 {
match rows.get(ix - 1) {
Some(row) if step(row) > new_pos => {
ix -= 1;
moves = moves.checked_add(1).unwrap_or(moves);
}
_ => break,
}
}
(ix, moves)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use optionstratlib::OptionStyle;
use proptest::prelude::*;
use super::{
EquityPoint, Fill, GreeksAttribution, LoadedBundle, Playback, PlaybackSpeed, PositionRow,
SeekTo, TimelineCursor, end_step_of, walk_index,
};
use crate::replay::{BundleManifest, ExecMode, PositionSide, SUPPORTED_SCHEMA};
const BASE_TS: i64 = 1_700_000_000_000_000_000;
const CID: &str = "v1:BTC:1735286400000000000:6000000:C";
const EXP_NS: i64 = 1_735_286_400_000_000_000;
const RUN: &str = "run-xyz";
fn ts(step: u32) -> i64 {
BASE_TS + i64::from(step)
}
fn manifest() -> BundleManifest {
let mut row_counts = BTreeMap::new();
let _ = row_counts.insert("fills".to_owned(), 0);
let _ = row_counts.insert("equity_curve".to_owned(), 0);
let _ = row_counts.insert("positions".to_owned(), 0);
let _ = row_counts.insert("greeks_attribution".to_owned(), 0);
BundleManifest {
schema: SUPPORTED_SCHEMA.to_owned(),
run_id: RUN.to_owned(),
created_utc: "2026-07-17T00:00:00Z".to_owned(),
code_version: "0.3.0".to_owned(),
lockfile_sha256: "deadbeef".to_owned(),
seed: 1,
config: serde_json::json!({ "initial_capital": 1_000_000 }),
strategy: serde_json::json!({ "kind": "iron_condor" }),
data_source: serde_json::json!({ "kind": "simulator" }),
metrics: serde_json::json!({}),
row_counts,
}
}
fn equity_point(step: u32) -> EquityPoint {
EquityPoint {
step,
ts_ns: ts(step),
cash_cents: 1_000 + i64::from(step),
position_value_cents: 0,
equity_cents: 1_000 + i64::from(step),
drawdown: -0.01,
}
}
fn greeks_row(step: u32) -> GreeksAttribution {
GreeksAttribution {
step,
ts_ns: ts(step),
theta_pnl_cents: 1,
delta_pnl_cents: 0,
vega_pnl_cents: 0,
spread_capture_cents: 0,
fees_cents: 0,
residual_cents: 0,
}
}
fn fill_at(step: u32, order_id: u64) -> Fill {
Fill {
step,
ts_ns: ts(step),
strategy_run_id: RUN.to_owned(),
trade_id: order_id,
position_id: order_id,
order_id,
fill_seq: 0,
underlying: "BTC".to_owned(),
expiration_ns: EXP_NS,
contract_id: CID.to_owned(),
strike_cents: 6_000_000,
style: OptionStyle::Call,
side: PositionSide::Long,
quantity: 1,
price_cents: 12_500,
fees_cents: 30,
slippage_cents: -15,
mode: ExecMode::Realistic,
}
}
fn position_at(
step: u32,
position_id: u64,
exit_reason: Option<&str>,
open_at_end: bool,
) -> PositionRow {
PositionRow {
step,
ts_ns: ts(step),
position_id,
trade_id: 7,
contract_id: CID.to_owned(),
side: PositionSide::Short,
quantity: 1,
avg_price_cents: 12_000,
mark_cents: 11_800,
unrealized_cents: 200,
stale_mark: false,
exit_reason: exit_reason.map(ToOwned::to_owned),
open_at_end,
}
}
fn bundle(n: u32, mut fills: Vec<Fill>, mut positions: Vec<PositionRow>) -> LoadedBundle {
fills.sort_by_key(|f| (f.step, f.order_id, f.fill_seq));
positions.sort_by_key(|p| (p.step, p.position_id));
LoadedBundle {
manifest: manifest(),
fills,
equity: (0..n).map(equity_point).collect(),
positions,
greeks: (0..n).map(greeks_row).collect(),
}
}
fn open_ids(cursor: &TimelineCursor, b: &LoadedBundle) -> Vec<u64> {
cursor
.open_positions(b)
.iter()
.map(|p| p.position_id)
.collect()
}
#[test]
fn test_new_starts_at_step_zero() {
let b = bundle(6, vec![], vec![]);
let c = TimelineCursor::new(&b);
assert_eq!(c.position, 0);
assert_eq!(c.end_step, 5);
assert!(c.is_at_start());
assert!(!c.is_at_end());
assert_eq!(c.equity_ix, 1);
assert_eq!(c.greeks_ix, 1);
}
#[test]
fn test_seek_step_to_mid_and_last() {
let b = bundle(6, vec![], vec![]);
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(3), &b);
assert_eq!(c.position, 3);
assert_eq!(c.equity_ix, 4);
c.seek(SeekTo::Step(5), &b);
assert_eq!(c.position, 5);
assert!(c.is_at_end());
}
#[test]
fn test_seek_clamps_out_of_range() {
let b = bundle(4, vec![], vec![]);
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(u32::MAX), &b);
assert_eq!(c.position, 3);
c.seek(SeekTo::StepBy(100), &b);
assert_eq!(c.position, 3);
c.seek(SeekTo::StepBy(-100), &b);
assert_eq!(c.position, 0);
c.seek(SeekTo::StepBy(-1), &b);
assert_eq!(c.position, 0);
}
#[test]
fn test_home_and_end_land_on_edges() {
let b = bundle(7, vec![], vec![]);
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(4), &b);
c.seek(SeekTo::Step(0), &b); assert_eq!(c.position, 0);
c.seek(SeekTo::Step(c.end_step), &b); assert_eq!(c.position, 6);
}
#[test]
fn test_incremental_and_arbitrary_agree_on_sparse_tables() {
let fills = vec![
fill_at(0, 10),
fill_at(2, 20),
fill_at(2, 21),
fill_at(5, 50),
];
let positions = vec![
position_at(1, 1, None, false),
position_at(4, 2, None, false),
];
let b = bundle(6, fills, positions);
for target in 0..=5 {
let mut arb = TimelineCursor::new(&b);
arb.seek(SeekTo::Step(target), &b);
let mut inc = TimelineCursor::new(&b);
for _ in 0..target {
inc.seek(SeekTo::StepBy(1), &b);
}
assert_eq!(arb, inc, "paths disagree at target {target}");
}
}
#[test]
fn test_stepby_walks_incrementally_without_rescan() {
let equity: Vec<EquityPoint> = (0..100).map(equity_point).collect();
let (ix, moves) = walk_index(&equity, 51, 51, |e| e.step);
assert_eq!(ix, 52);
assert_eq!(moves, 1);
let (ix2, moves2) = walk_index(&equity, 52, 40, |e| e.step);
assert_eq!(ix2, 41);
assert_eq!(moves2, 11);
let (ix3, moves3) = walk_index(&equity, 41, 40, |e| e.step);
assert_eq!(ix3, 41);
assert_eq!(moves3, 0);
}
#[test]
fn test_as_of_slices_are_consistent_at_one_step() {
let fills = vec![
fill_at(0, 10),
fill_at(2, 20),
fill_at(2, 21),
fill_at(5, 50),
];
let b = bundle(6, fills, vec![]);
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(2), &b);
match c.head_equity(&b) {
Some(e) => assert_eq!(e.step, 2),
None => panic!("head_equity must exist at step 2"),
}
match c.head_greeks(&b) {
Some(g) => assert_eq!(g.step, 2),
None => panic!("head_greeks must exist at step 2"),
}
assert!(c.visible_equity(&b).iter().all(|e| e.step <= 2));
assert!(c.visible_fills(&b).iter().all(|f| f.step <= 2));
assert_eq!(c.visible_equity(&b).len(), 3); let head = c.head_fills(&b);
assert_eq!(head.len(), 2);
assert!(head.iter().all(|f| f.step == 2));
match c.visible_greeks(&b).last() {
Some(g) => assert_eq!(g.step, 2),
None => panic!("visible greeks must be non-empty at step 2"),
}
}
#[test]
fn test_head_fills_empty_when_no_fill_on_head_step() {
let fills = vec![fill_at(0, 10), fill_at(2, 20)];
let b = bundle(4, fills, vec![]);
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(1), &b); assert!(c.head_fills(&b).is_empty());
assert_eq!(c.visible_fills(&b).len(), 1); }
fn open_close_bundle() -> LoadedBundle {
let mut positions = Vec::new();
for s in 0..=5 {
positions.push(position_at(s, 2, None, s == 5));
}
positions.push(position_at(1, 1, None, false));
positions.push(position_at(2, 1, None, false));
positions.push(position_at(3, 1, Some("target"), false));
positions.push(position_at(4, 3, Some("stop"), false));
bundle(6, vec![], positions)
}
#[test]
fn test_open_positions_before_any_open() {
let b = open_close_bundle();
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(0), &b);
assert_eq!(open_ids(&c, &b), vec![2]); }
#[test]
fn test_open_positions_shows_latest_non_terminal_row() {
let b = open_close_bundle();
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(2), &b);
assert_eq!(open_ids(&c, &b), vec![1, 2]);
let a_row = c
.open_positions(&b)
.into_iter()
.find(|p| p.position_id == 1);
match a_row {
Some(p) => assert_eq!(p.step, 2),
None => panic!("leg A must be open at step 2"),
}
}
#[test]
fn test_open_positions_excludes_terminated_leg() {
let b = open_close_bundle();
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(3), &b); assert_eq!(open_ids(&c, &b), vec![2]); }
#[test]
fn test_open_positions_same_step_open_close_is_excluded() {
let b = open_close_bundle();
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(4), &b); assert_eq!(open_ids(&c, &b), vec![2]); }
#[test]
fn test_open_at_end_leg_stays_open_through_the_end() {
let b = open_close_bundle();
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(c.end_step), &b);
assert_eq!(open_ids(&c, &b), vec![2]); let b_row = c
.open_positions(&b)
.into_iter()
.find(|p| p.position_id == 2);
match b_row {
Some(p) => {
assert_eq!(p.step, 5);
assert!(p.open_at_end);
}
None => panic!("open_at_end leg must be open at end_step"),
}
}
#[test]
fn test_empty_run_is_panic_free() {
let b = bundle(0, vec![], vec![]);
let mut c = TimelineCursor::new(&b);
assert_eq!(c.end_step, 0);
assert_eq!(c.position, 0);
assert!(c.head_equity(&b).is_none());
assert!(c.head_greeks(&b).is_none());
assert!(c.visible_equity(&b).is_empty());
assert!(c.open_positions(&b).is_empty());
c.seek(SeekTo::StepBy(1), &b);
c.seek(SeekTo::StepBy(-1), &b);
c.seek(SeekTo::Step(9), &b);
assert_eq!(c.position, 0);
}
#[test]
fn test_single_step_run() {
let b = bundle(1, vec![fill_at(0, 10)], vec![position_at(0, 1, None, true)]);
let mut c = TimelineCursor::new(&b);
assert_eq!(c.end_step, 0);
assert!(c.is_at_start() && c.is_at_end());
c.seek(SeekTo::StepBy(1), &b);
assert_eq!(c.position, 0);
c.seek(SeekTo::Step(9), &b);
assert_eq!(c.position, 0);
assert_eq!(open_ids(&c, &b), vec![1]);
match c.head_equity(&b) {
Some(e) => assert_eq!(e.step, 0),
None => panic!("single-step run must have a head equity row"),
}
}
#[test]
fn test_playback_defaults_paused_and_yields_no_seek() {
assert_eq!(Playback::default(), Playback::Paused);
assert!(!Playback::Paused.is_playing());
assert_eq!(Playback::Paused.tick_seek(), None);
assert_eq!(Playback::Paused.speed(), None);
}
#[test]
fn test_playback_tick_seek_scales_by_speed() {
assert_eq!(
Playback::playing(PlaybackSpeed::X1).tick_seek(),
Some(SeekTo::StepBy(1))
);
assert_eq!(
Playback::playing(PlaybackSpeed::X2).tick_seek(),
Some(SeekTo::StepBy(2))
);
assert_eq!(
Playback::playing(PlaybackSpeed::X5).tick_seek(),
Some(SeekTo::StepBy(5))
);
assert_eq!(
Playback::playing(PlaybackSpeed::X10).tick_seek(),
Some(SeekTo::StepBy(10))
);
}
#[test]
fn test_playback_speed_helpers() {
assert_eq!(PlaybackSpeed::default(), PlaybackSpeed::X1);
assert_eq!(PlaybackSpeed::ALL.len(), 4);
assert_eq!(PlaybackSpeed::X2.multiplier(), 2);
assert_eq!(PlaybackSpeed::X10.quantum(), 10);
assert_eq!(PlaybackSpeed::X1.faster(), PlaybackSpeed::X2);
assert_eq!(PlaybackSpeed::X10.faster(), PlaybackSpeed::X10);
assert_eq!(PlaybackSpeed::X10.slower(), PlaybackSpeed::X5);
assert_eq!(PlaybackSpeed::X1.slower(), PlaybackSpeed::X1);
}
#[test]
fn test_playback_toggle() {
let paused = Playback::Paused;
let playing = paused.toggled(PlaybackSpeed::X5);
assert_eq!(playing, Playback::playing(PlaybackSpeed::X5));
assert_eq!(playing.toggled(PlaybackSpeed::X1), Playback::Paused);
}
#[test]
fn test_advance_playback_stops_at_end_without_wrapping() {
let b = bundle(5, vec![], vec![]); let playback = Playback::playing(PlaybackSpeed::X2);
let mut c = TimelineCursor::new(&b);
let mut seen = Vec::new();
for _ in 0..10 {
c.advance_playback(playback, &b);
seen.push(c.position);
}
assert_eq!(seen.first(), Some(&2));
assert_eq!(c.position, 4);
assert!(seen.iter().all(|&p| p <= 4));
assert!(seen.windows(2).all(|w| match w {
[a, b] => b >= a,
_ => true,
}));
}
#[test]
fn test_advance_playback_paused_is_a_no_op() {
let b = bundle(5, vec![], vec![]);
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(2), &b);
let before = c;
c.advance_playback(Playback::Paused, &b);
assert_eq!(before, c);
}
prop_compose! {
fn arb_bundle()(
n in 1u32..16,
fill_steps in prop::collection::vec(0u32..15, 0..24),
pos_steps in prop::collection::vec(0u32..15, 0..24),
) -> LoadedBundle {
let fills = fill_steps
.iter()
.enumerate()
.map(|(i, s)| fill_at(*s % n, u64::try_from(i).unwrap_or_default()))
.collect();
let positions = pos_steps
.iter()
.enumerate()
.map(|(i, s)| position_at(*s % n, u64::try_from(i).unwrap_or_default(), None, false))
.collect();
bundle(n, fills, positions)
}
}
fn arb_seek() -> impl Strategy<Value = SeekTo> {
prop_oneof![
(0u32..30).prop_map(SeekTo::Step),
(-6i32..6).prop_map(SeekTo::StepBy),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn incremental_equals_arbitrary(b in arb_bundle(), target in 0u32..30) {
let clamped = target.min(end_step_of(&b));
let mut arb = TimelineCursor::new(&b);
arb.seek(SeekTo::Step(target), &b);
let mut inc = TimelineCursor::new(&b);
for _ in 0..clamped {
inc.seek(SeekTo::StepBy(1), &b);
}
prop_assert_eq!(arb, inc);
prop_assert_eq!(arb.position, clamped);
}
#[test]
fn seek_lands_on_last_le_step(
b in arb_bundle(),
seeks in prop::collection::vec(arb_seek(), 1..12),
) {
let mut c = TimelineCursor::new(&b);
for s in seeks {
c.seek(s, &b);
prop_assert!(c.position <= c.end_step);
prop_assert_eq!(c.fills_ix, b.fills.partition_point(|f| f.step <= c.position));
prop_assert_eq!(c.equity_ix, b.equity.partition_point(|e| e.step <= c.position));
prop_assert_eq!(
c.positions_ix,
b.positions.partition_point(|p| p.step <= c.position)
);
prop_assert_eq!(c.greeks_ix, b.greeks.partition_point(|g| g.step <= c.position));
}
}
#[test]
fn seek_is_idempotent(b in arb_bundle(), target in 0u32..30) {
let mut once = TimelineCursor::new(&b);
once.seek(SeekTo::Step(target), &b);
let mut twice = once;
twice.seek(SeekTo::Step(target), &b);
prop_assert_eq!(once, twice);
let mut noop = once;
noop.seek(SeekTo::StepBy(0), &b);
prop_assert_eq!(once, noop);
}
#[test]
fn step_then_back_returns(b in arb_bundle(), start in 0u32..30, delta in 1i32..6) {
let mut c = TimelineCursor::new(&b);
c.seek(SeekTo::Step(start), &b);
let base = c;
let d = u32::try_from(delta).unwrap_or(0);
if base.position >= d && base.position + d <= base.end_step {
c.seek(SeekTo::StepBy(delta), &b);
c.seek(SeekTo::StepBy(-delta), &b);
prop_assert_eq!(base, c);
}
}
}
}