Skip to main content

moq_net/model/
time.rs

1use std::num::NonZero;
2
3use crate::coding::VarInt;
4
5/// Returned when a [`Timestamp`] operation would exceed the QUIC VarInt range
6/// (`2^62 - 1`), overflow during scale conversion or arithmetic, or attempt
7/// arithmetic between timestamps with mismatched scales.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
9#[error("time overflow")]
10pub struct TimeOverflow;
11
12/// Units per second used by a track for frame timestamps.
13///
14/// Newtype around [`NonZero<u64>`]. Zero is structurally impossible, so the
15/// arithmetic on [`Timestamp`] can divide by `self.scale` without ever risking
16/// a divide by zero. Use the named constants ([`Self::SECOND`], [`Self::MILLI`],
17/// [`Self::MICRO`], [`Self::NANO`]) instead of writing raw integers at call sites;
18/// for runtime values, use [`Self::new`] which returns [`TimeOverflow`] for `0` or
19/// for values past the QUIC varint range.
20#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub struct Timescale(NonZero<u64>);
22
23impl Timescale {
24	/// One unit per second (`1`).
25	pub const SECOND: Self = Self(NonZero::<u64>::MIN);
26	/// 1,000 units per second (`1_000`).
27	pub const MILLI: Self = match NonZero::new(1_000) {
28		Some(n) => Self(n),
29		None => unreachable!(),
30	};
31	/// 1,000,000 units per second (`1_000_000`). Widely used by container formats;
32	/// this crate's own default is [`Self::MILLI`].
33	pub const MICRO: Self = match NonZero::new(1_000_000) {
34		Some(n) => Self(n),
35		None => unreachable!(),
36	};
37	/// 1,000,000,000 units per second (`1_000_000_000`).
38	pub const NANO: Self = match NonZero::new(1_000_000_000) {
39		Some(n) => Self(n),
40		None => unreachable!(),
41	};
42
43	/// Construct a timescale from a raw value (units per second).
44	///
45	/// Returns [`TimeOverflow`] if `units_per_second` is `0` (would divide by zero)
46	/// or exceeds `2^62 - 1` (the QUIC varint range, matching [`Timestamp`] values).
47	pub const fn new(units_per_second: u64) -> Result<Self, TimeOverflow> {
48		// Reject values that wouldn't fit in a QUIC varint, keeping the constraint
49		// symmetric with Timestamp's raw value.
50		if VarInt::from_u64(units_per_second).is_none() {
51			return Err(TimeOverflow);
52		}
53		match NonZero::new(units_per_second) {
54			Some(n) => Ok(Self(n)),
55			None => Err(TimeOverflow),
56		}
57	}
58
59	/// The raw units-per-second value (always non-zero).
60	pub const fn as_u64(self) -> u64 {
61		self.0.get()
62	}
63}
64
65impl TryFrom<u64> for Timescale {
66	type Error = TimeOverflow;
67
68	fn try_from(units_per_second: u64) -> Result<Self, Self::Error> {
69		Self::new(units_per_second)
70	}
71}
72
73impl From<NonZero<u64>> for Timescale {
74	fn from(units_per_second: NonZero<u64>) -> Self {
75		Self(units_per_second)
76	}
77}
78
79impl From<Timescale> for u64 {
80	fn from(scale: Timescale) -> Self {
81		scale.0.get()
82	}
83}
84
85impl From<Timescale> for NonZero<u64> {
86	fn from(scale: Timescale) -> Self {
87		scale.0
88	}
89}
90
91impl Default for Timescale {
92	/// Milliseconds ([`Self::MILLI`]). Every track has a timescale; this is the one
93	/// used when a producer doesn't pick one and the fallback for protocols whose wire
94	/// can't carry a timescale (pre-Lite05 moq-lite, IETF moq-transport).
95	fn default() -> Self {
96		Self::MILLI
97	}
98}
99
100impl std::fmt::Debug for Timescale {
101	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102		match *self {
103			Self::SECOND => write!(f, "Timescale::SECOND"),
104			Self::MILLI => write!(f, "Timescale::MILLI"),
105			Self::MICRO => write!(f, "Timescale::MICRO"),
106			Self::NANO => write!(f, "Timescale::NANO"),
107			Self(n) => write!(f, "Timescale({n})"),
108		}
109	}
110}
111
112impl std::fmt::Display for Timescale {
113	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114		write!(f, "{}", self.0)
115	}
116}
117
118/// A timestamp in a track's timescale (units per second).
119///
120/// All timestamps within a track are relative, so zero for one track is not zero for another.
121/// The underlying value is constrained to fit within a QUIC VarInt (`2^62 - 1`) so it can be
122/// encoded and decoded easily; the scale is carried alongside so frames from different
123/// sources can be compared and converted without lossy detours through a single fixed scale.
124///
125/// The scale is a [`Timescale`] (always non-zero), so unit conversions (`as_secs`, `as_millis`,
126/// etc.) are infallible. Use [`Option<Timestamp>`] at call sites that need a "missing" sentinel
127/// instead of relying on a magic value.
128///
129/// # An instant, not a number
130///
131/// A `Timestamp` is a point in time (like [`std::time::Instant`]), not a scalar, so it has no
132/// arithmetic operators: adding two instants is meaningless, and a scale mismatch can't be a
133/// silent panic. Use [`Self::checked_add`] / [`Self::checked_sub`], which **require both
134/// operands to share a scale** and return [`TimeOverflow`] otherwise. To combine timestamps
135/// from different scales, [`Self::convert`] one to the other's scale first.
136///
137/// # Equality vs ordering
138///
139/// These two intentionally disagree, so pick the one you mean:
140///
141/// - [`Eq`] / [`Hash`] are **structural** (field-wise): `from_secs(1) != from_millis(1000)`,
142///   because they encode as different `(value, scale)` pairs on the wire. Two timestamps are
143///   equal only when both their value and scale match.
144/// - [`Ord`] is **temporal**: it cross-multiplies scales, so `from_millis(1000)` orders after
145///   `from_millis(999)` and `from_secs(1)` slots in between. When a cross-scale comparison is
146///   otherwise a tie, it breaks by `(scale, value)` to stay consistent with `Eq`.
147///
148/// So `from_secs(1).cmp(&from_millis(1000))` is *not* `Equal`, and neither is `==` true. If you
149/// want "same instant regardless of encoding", compare after a [`Self::convert`] to a common scale.
150#[derive(Clone, Copy, PartialEq, Eq, Hash)]
151pub struct Timestamp {
152	value: VarInt,
153	scale: Timescale,
154}
155
156impl Timestamp {
157	/// The zero timestamp: value `0` at [`Timescale::SECOND`].
158	///
159	/// The scale is not incidental. Equality and ordering are scale-aware (see the type
160	/// docs), so this is *not* interchangeable with `0` at another scale; use
161	/// [`Self::is_zero`] to test a zero value regardless of scale. In particular, don't
162	/// seed a `.max()` accumulator with this: a later value at a finer scale would lose
163	/// the tie-break. Reach for `Option<Timestamp>` instead.
164	pub const ZERO: Self = Self::new_const(0, Timescale::SECOND);
165
166	/// Construct a timestamp directly from a raw value at the given scale.
167	/// Returns [`TimeOverflow`] if `value` exceeds `2^62 - 1`.
168	pub const fn new(value: u64, scale: Timescale) -> Result<Self, TimeOverflow> {
169		match VarInt::from_u64(value) {
170			Some(value) => Ok(Self { value, scale }),
171			None => Err(TimeOverflow),
172		}
173	}
174
175	/// Const-context twin of [`Self::new`] that panics on overflow.
176	///
177	/// For building `const` timestamps where `?`/`unwrap` on the [`Result`] isn't
178	/// available. The panic fires only on a compile-time-known out-of-range literal, so
179	/// it's a build-time assertion, not a runtime failure path. Use [`Self::new`]
180	/// everywhere else.
181	pub const fn new_const(value: u64, scale: Timescale) -> Self {
182		match Self::new(value, scale) {
183			Ok(time) => time,
184			Err(_) => panic!("timestamp value exceeds 2^62 - 1"),
185		}
186	}
187
188	/// Construct a timestamp from a raw value and a `units_per_second` scale.
189	/// Returns [`TimeOverflow`] if the scale is zero or the value is out of range.
190	pub fn from_scale(value: u64, units_per_second: u64) -> Result<Self, TimeOverflow> {
191		Self::new(value, Timescale::new(units_per_second)?)
192	}
193
194	/// Convert a number of seconds to a timestamp at [`Timescale::SECOND`].
195	pub const fn from_secs(seconds: u64) -> Result<Self, TimeOverflow> {
196		Self::new(seconds, Timescale::SECOND)
197	}
198
199	/// Convert a number of milliseconds to a timestamp at [`Timescale::MILLI`].
200	pub const fn from_millis(millis: u64) -> Result<Self, TimeOverflow> {
201		Self::new(millis, Timescale::MILLI)
202	}
203
204	/// Convert a number of microseconds to a timestamp at [`Timescale::MICRO`].
205	pub const fn from_micros(micros: u64) -> Result<Self, TimeOverflow> {
206		Self::new(micros, Timescale::MICRO)
207	}
208
209	/// Convert a number of nanoseconds to a timestamp at [`Timescale::NANO`].
210	pub const fn from_nanos(nanos: u64) -> Result<Self, TimeOverflow> {
211		Self::new(nanos, Timescale::NANO)
212	}
213
214	/// The raw value in the timestamp's own scale.
215	pub const fn value(self) -> u64 {
216		self.value.into_inner()
217	}
218
219	/// The scale (units per second) attached to this timestamp.
220	pub const fn scale(self) -> Timescale {
221		self.scale
222	}
223
224	/// Whether the raw value is zero. Does not consider scale.
225	pub const fn is_zero(self) -> bool {
226		self.value.into_inner() == 0
227	}
228
229	/// Re-express this timestamp at a new scale. Returns [`TimeOverflow`] if the new
230	/// value would exceed `2^62 - 1`.
231	pub const fn convert(self, new_scale: Timescale) -> Result<Self, TimeOverflow> {
232		if self.scale.0.get() == new_scale.0.get() {
233			return Ok(self);
234		}
235		match (self.value.into_inner() as u128).checked_mul(new_scale.0.get() as u128) {
236			Some(scaled) => match VarInt::from_u128(scaled / self.scale.0.get() as u128) {
237				Some(value) => Ok(Self {
238					value,
239					scale: new_scale,
240				}),
241				None => Err(TimeOverflow),
242			},
243			None => Err(TimeOverflow),
244		}
245	}
246
247	/// The value re-expressed at `target` as a `u128`.
248	pub const fn as_scale(self, target: Timescale) -> u128 {
249		self.value.into_inner() as u128 * target.0.get() as u128 / self.scale.0.get() as u128
250	}
251
252	/// The value re-expressed in seconds.
253	pub const fn as_secs(self) -> u64 {
254		self.value.into_inner() / self.scale.0.get()
255	}
256
257	/// The value re-expressed in milliseconds.
258	pub const fn as_millis(self) -> u128 {
259		self.as_scale(Timescale::MILLI)
260	}
261
262	/// The value re-expressed in microseconds.
263	pub const fn as_micros(self) -> u128 {
264		self.as_scale(Timescale::MICRO)
265	}
266
267	/// The value re-expressed in nanoseconds.
268	pub const fn as_nanos(self) -> u128 {
269		self.as_scale(Timescale::NANO)
270	}
271
272	/// Add two timestamps. Returns [`TimeOverflow`] if the sum exceeds `2^62 - 1` or
273	/// if the scales differ.
274	pub const fn checked_add(self, rhs: Self) -> Result<Self, TimeOverflow> {
275		if self.scale.0.get() != rhs.scale.0.get() {
276			return Err(TimeOverflow);
277		}
278		match self.value.into_inner().checked_add(rhs.value.into_inner()) {
279			Some(result) => Self::new(result, self.scale),
280			None => Err(TimeOverflow),
281		}
282	}
283
284	/// Subtract `rhs` from `self`. Returns [`TimeOverflow`] if `rhs > self` or if the
285	/// scales differ.
286	pub const fn checked_sub(self, rhs: Self) -> Result<Self, TimeOverflow> {
287		if self.scale.0.get() != rhs.scale.0.get() {
288			return Err(TimeOverflow);
289		}
290		match self.value.into_inner().checked_sub(rhs.value.into_inner()) {
291			Some(result) => Self::new(result, self.scale),
292			None => Err(TimeOverflow),
293		}
294	}
295
296	/// Current point on the local monotonic clock, expressed in the default timescale
297	/// ([`Timescale::MILLI`]).
298	///
299	/// This is the one-way bridge from a local clock to a track timestamp: there is
300	/// deliberately no inverse (a [`Timestamp`] is relative and jittered, never a clock).
301	/// Used to stamp frames that arrive without one, e.g. on protocols whose wire can't
302	/// carry a timestamp. Uses [`web_async::time::Instant::now`] so it works on wasm and honors
303	/// `tokio::time::pause` in tests.
304	pub fn now() -> Self {
305		clock::now()
306	}
307}
308
309impl TryFrom<std::time::Duration> for Timestamp {
310	type Error = TimeOverflow;
311
312	/// Convert a [`std::time::Duration`] into a nanosecond-scale timestamp.
313	fn try_from(duration: std::time::Duration) -> Result<Self, Self::Error> {
314		match VarInt::from_u128(duration.as_nanos()) {
315			Some(value) => Ok(Self {
316				value,
317				scale: Timescale::NANO,
318			}),
319			None => Err(TimeOverflow),
320		}
321	}
322}
323
324impl From<Timestamp> for std::time::Duration {
325	fn from(time: Timestamp) -> Self {
326		let nanos = time.as_nanos();
327		std::time::Duration::new(time.as_secs(), (nanos % 1_000_000_000) as u32)
328	}
329}
330
331impl std::fmt::Debug for Timestamp {
332	#[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable in Rust 1.85
333	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334		let nanos = self.as_nanos();
335
336		// Choose the largest unit where we don't need decimal places.
337		if nanos % 1_000_000_000 == 0 {
338			write!(f, "{}s", nanos / 1_000_000_000)
339		} else if nanos % 1_000_000 == 0 {
340			write!(f, "{}ms", nanos / 1_000_000)
341		} else if nanos % 1_000 == 0 {
342			write!(f, "{}µs", nanos / 1_000)
343		} else {
344			write!(f, "{}ns", nanos)
345		}
346	}
347}
348
349impl PartialOrd for Timestamp {
350	fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
351		Some(self.cmp(other))
352	}
353}
354
355impl Ord for Timestamp {
356	/// Temporal comparison, normalizing across scales (see the type-level docs for how
357	/// this relates to structural `Eq`).
358	///
359	/// - Equal scales compare raw values directly.
360	/// - Otherwise cross-multiplies in 128-bit so e.g. `1s > 2ms` orders correctly.
361	/// - A would-be cross-scale tie (e.g. `from_secs(1)` vs `from_millis(1000)`) breaks by
362	///   `(scale, value)`, keeping `Ord` consistent with the field-wise `Eq`/`Hash`.
363	fn cmp(&self, other: &Self) -> std::cmp::Ordering {
364		if self.scale.0.get() == other.scale.0.get() {
365			return self.value.cmp(&other.value);
366		}
367		let lhs = self.value.into_inner() as u128 * other.scale.0.get() as u128;
368		let rhs = other.value.into_inner() as u128 * self.scale.0.get() as u128;
369		lhs.cmp(&rhs)
370			.then_with(|| self.scale.0.get().cmp(&other.scale.0.get()))
371			.then_with(|| self.value.cmp(&other.value))
372	}
373}
374
375#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
376mod clock {
377	use std::sync::LazyLock;
378	use std::time::{SystemTime, UNIX_EPOCH};
379
380	use rand::RngExt;
381
382	use super::Timestamp;
383
384	/// Epoch the wall-clock timestamps are measured from: 2020-01-01T00:00:00Z.
385	///
386	/// A [`Timestamp`] isn't a real clock, it just needs to be non-negative and roughly
387	/// monotonic with wall time. Anchoring 50 years after the Unix epoch keeps the value
388	/// ~1.5e12 ms smaller, trimming a byte or two off the first frame's varint.
389	const ANCHOR_EPOCH_SECS: u64 = 1_577_836_800;
390
391	// There's no zero Instant, so we need to use a reference point.
392	static TIME_ANCHOR: LazyLock<(std::time::Instant, SystemTime)> = LazyLock::new(|| {
393		// To deter nerds trying to use timestamp as wall clock time, we subtract a random amount of time from the anchor.
394		// This will make our timestamps appear to be late; just enough to be annoying and obscure our clock drift.
395		// This will also catch bad implementations that assume unrelated broadcasts are synchronized.
396		let jitter = std::time::Duration::from_millis(rand::rng().random_range(0..69_420));
397		(std::time::Instant::now(), SystemTime::now() - jitter)
398	});
399
400	pub(super) fn now() -> Timestamp {
401		let instant: std::time::Instant = web_async::time::Instant::now().into();
402		from_std_instant(instant)
403	}
404
405	fn from_std_instant(instant: std::time::Instant) -> Timestamp {
406		let (anchor_instant, anchor_system) = *TIME_ANCHOR;
407
408		let system = match instant.checked_duration_since(anchor_instant) {
409			Some(forward) => anchor_system + forward,
410			None => anchor_system - anchor_instant.duration_since(instant),
411		};
412
413		let epoch = UNIX_EPOCH + std::time::Duration::from_secs(ANCHOR_EPOCH_SECS);
414		// Saturate to zero rather than panic if the wall clock is before 2020 (an unsynced
415		// clock on a peer-driven path), since the only requirement is a non-negative start.
416		let duration = system.duration_since(epoch).unwrap_or(std::time::Duration::ZERO);
417
418		Timestamp::from_millis(duration.as_millis() as u64).expect("clock is somehow past the year 2300")
419	}
420
421	impl From<std::time::Instant> for Timestamp {
422		/// Convert an [`std::time::Instant`] into a millisecond-scale timestamp (the default
423		/// timescale), anchored at 2020-01-01 plus a per-process jitter (see `TIME_ANCHOR`).
424		///
425		/// One-way only: there is no inverse, since the anchor is jittered to keep a
426		/// [`Timestamp`] from being read back as a clock.
427		fn from(instant: std::time::Instant) -> Self {
428			from_std_instant(instant)
429		}
430	}
431}
432
433#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
434mod clock {
435	use std::sync::LazyLock;
436
437	use rand::RngExt;
438
439	use super::Timestamp;
440
441	static TIME_ANCHOR: LazyLock<(web_async::time::Instant, std::time::Duration)> = LazyLock::new(|| {
442		let jitter = std::time::Duration::from_millis(rand::rng().random_range(1..69_420));
443		(web_async::time::Instant::now(), jitter)
444	});
445
446	pub(super) fn now() -> Timestamp {
447		let (anchor_instant, anchor_duration) = *TIME_ANCHOR;
448		let instant = web_async::time::Instant::now();
449		let duration = match instant.checked_duration_since(anchor_instant) {
450			Some(forward) => anchor_duration + forward,
451			None => anchor_duration
452				.checked_sub(anchor_instant.duration_since(instant))
453				.unwrap_or(std::time::Duration::ZERO),
454		};
455
456		Timestamp::from_millis(duration.as_millis() as u64).expect("clock is somehow past the year 2300")
457	}
458}
459
460#[cfg(test)]
461mod tests {
462	use super::*;
463
464	#[test]
465	fn test_from_secs() {
466		let time = Timestamp::from_secs(5).unwrap();
467		assert_eq!(time.scale(), Timescale::SECOND);
468		assert_eq!(time.as_secs(), 5);
469		assert_eq!(time.as_millis(), 5000);
470		assert_eq!(time.as_micros(), 5_000_000);
471		assert_eq!(time.as_nanos(), 5_000_000_000);
472	}
473
474	#[test]
475	fn test_from_millis() {
476		let time = Timestamp::from_millis(5000).unwrap();
477		assert_eq!(time.scale(), Timescale::MILLI);
478		assert_eq!(time.as_secs(), 5);
479		assert_eq!(time.as_millis(), 5000);
480	}
481
482	#[test]
483	fn test_from_micros() {
484		let time = Timestamp::from_micros(5_000_000).unwrap();
485		assert_eq!(time.scale(), Timescale::MICRO);
486		assert_eq!(time.as_secs(), 5);
487		assert_eq!(time.as_micros(), 5_000_000);
488	}
489
490	#[test]
491	fn test_from_nanos() {
492		let time = Timestamp::from_nanos(5_000_000_000).unwrap();
493		assert_eq!(time.scale(), Timescale::NANO);
494		assert_eq!(time.as_secs(), 5);
495		assert_eq!(time.as_nanos(), 5_000_000_000);
496	}
497
498	#[test]
499	fn test_timescale_new_rejects_zero_and_overflow() {
500		assert!(Timescale::new(0).is_err());
501		assert!(Timescale::new(1).is_ok());
502		assert_eq!(Timescale::new(1).unwrap(), Timescale::SECOND);
503		assert_eq!(Timescale::new(1_000).unwrap(), Timescale::MILLI);
504
505		// Above the QUIC varint range.
506		assert!(Timescale::new(1u64 << 62).is_err());
507		// Right at the top of the varint range is still valid.
508		assert!(Timescale::new((1u64 << 62) - 1).is_ok());
509	}
510
511	#[test]
512	fn test_convert_to_finer() {
513		let time_ms = Timestamp::from_millis(5000).unwrap();
514		let time_us = time_ms.convert(Timescale::MICRO).unwrap();
515		assert_eq!(time_us.scale(), Timescale::MICRO);
516		assert_eq!(time_us.as_micros(), 5_000_000);
517	}
518
519	#[test]
520	fn test_convert_to_coarser() {
521		let time_ms = Timestamp::from_millis(5000).unwrap();
522		let time_s = time_ms.convert(Timescale::SECOND).unwrap();
523		assert_eq!(time_s.scale(), Timescale::SECOND);
524		assert_eq!(time_s.as_secs(), 5);
525	}
526
527	#[test]
528	fn test_convert_precision_loss() {
529		// 1234 ms = 1.234 s, rounds down to 1 s
530		let time_ms = Timestamp::from_millis(1234).unwrap();
531		let time_s = time_ms.convert(Timescale::SECOND).unwrap();
532		assert_eq!(time_s.as_secs(), 1);
533	}
534
535	#[test]
536	fn test_convert_roundtrip() {
537		let original = Timestamp::from_millis(5000).unwrap();
538		let as_micros = original.convert(Timescale::MICRO).unwrap();
539		let back = as_micros.convert(Timescale::MILLI).unwrap();
540		assert_eq!(original.value(), back.value());
541		assert_eq!(original.scale(), back.scale());
542	}
543
544	#[test]
545	fn test_convert_same_scale() {
546		let time = Timestamp::from_millis(5000).unwrap();
547		let converted = time.convert(Timescale::MILLI).unwrap();
548		assert_eq!(time, converted);
549	}
550
551	#[test]
552	fn test_add_same_scale() {
553		let a = Timestamp::from_millis(1000).unwrap();
554		let b = Timestamp::from_millis(2000).unwrap();
555		let c = a.checked_add(b).unwrap();
556		assert_eq!(c.as_millis(), 3000);
557		assert_eq!(c.scale(), Timescale::MILLI);
558	}
559
560	#[test]
561	fn test_add_mismatched_scale() {
562		let a = Timestamp::from_millis(1000).unwrap();
563		let b = Timestamp::from_micros(1000).unwrap();
564		assert!(a.checked_add(b).is_err());
565	}
566
567	#[test]
568	fn test_new_const_matches_fallible() {
569		const C: Timestamp = Timestamp::new_const(42, Timescale::MICRO);
570		assert_eq!(C, Timestamp::new(42, Timescale::MICRO).unwrap());
571	}
572
573	#[test]
574	fn test_zero_is_scale_aware() {
575		// ZERO is second-scale. is_zero() sees the value regardless of scale, but
576		// equality is structural, so it's not interchangeable with 0 at another scale.
577		assert!(Timestamp::ZERO.is_zero());
578		let zero_ms = Timestamp::from_millis(0).unwrap();
579		assert!(zero_ms.is_zero());
580		assert_ne!(Timestamp::ZERO, zero_ms);
581		assert_ne!(Timestamp::ZERO.cmp(&zero_ms), std::cmp::Ordering::Equal);
582	}
583
584	#[test]
585	fn test_sub_underflow() {
586		let a = Timestamp::from_millis(1000).unwrap();
587		let b = Timestamp::from_millis(2000).unwrap();
588		assert!(a.checked_sub(b).is_err());
589	}
590
591	#[test]
592	fn test_max_same_scale() {
593		let a = Timestamp::from_secs(5).unwrap();
594		let b = Timestamp::from_secs(10).unwrap();
595		assert_eq!(a.max(b), b);
596		assert_eq!(b.max(a), b);
597	}
598
599	#[test]
600	fn test_max_cross_scale() {
601		// `Ord::max` compares across scales (no panic).
602		let a = Timestamp::from_millis(1).unwrap();
603		let b = Timestamp::from_secs(1).unwrap();
604		assert_eq!(a.max(b), b);
605	}
606
607	#[test]
608	fn test_ordering_same_scale() {
609		let a = Timestamp::from_secs(1).unwrap();
610		let b = Timestamp::from_secs(2).unwrap();
611		assert!(a < b);
612		assert!(b > a);
613		assert_eq!(a, a);
614	}
615
616	#[test]
617	fn test_ordering_across_known_scales() {
618		// Cross-scale ordering normalizes to a common scale.
619		let one_sec = Timestamp::from_secs(1).unwrap();
620		let two_ms = Timestamp::from_millis(2).unwrap();
621		assert!(one_sec > two_ms);
622		assert!(two_ms < one_sec);
623
624		// Temporally-equivalent timestamps with different representations are NOT
625		// Equal under cmp: derived Eq compares fields, and Ord must agree.
626		let one_sec_b = Timestamp::from_millis(1000).unwrap();
627		assert_ne!(one_sec.cmp(&one_sec_b), std::cmp::Ordering::Equal);
628		assert_ne!(one_sec, one_sec_b);
629		assert_eq!(one_sec.cmp(&one_sec), std::cmp::Ordering::Equal);
630
631		// Mixed-scale sort lands in correct temporal order.
632		let mut items = [
633			Timestamp::from_secs(2).unwrap(),
634			Timestamp::from_millis(500).unwrap(),
635			Timestamp::from_micros(1_500_000).unwrap(),
636		];
637		items.sort();
638		assert_eq!(items[0], Timestamp::from_millis(500).unwrap());
639		assert_eq!(items[1], Timestamp::from_micros(1_500_000).unwrap());
640		assert_eq!(items[2], Timestamp::from_secs(2).unwrap());
641	}
642
643	#[test]
644	fn test_duration_conversion() {
645		let duration = std::time::Duration::from_secs(5);
646		let time: Timestamp = duration.try_into().unwrap();
647		assert_eq!(time.scale(), Timescale::NANO);
648		assert_eq!(time.as_secs(), 5);
649
650		let duration_back: std::time::Duration = time.into();
651		assert_eq!(duration_back.as_secs(), 5);
652	}
653
654	#[test]
655	fn test_debug_format_units() {
656		let t = Timestamp::from_millis(100_000).unwrap();
657		assert_eq!(format!("{:?}", t), "100s");
658
659		let t = Timestamp::from_millis(100).unwrap();
660		assert_eq!(format!("{:?}", t), "100ms");
661
662		let t = Timestamp::from_micros(1500).unwrap();
663		assert_eq!(format!("{:?}", t), "1500µs");
664
665		let t = Timestamp::from_micros(1000).unwrap();
666		assert_eq!(format!("{:?}", t), "1ms");
667	}
668
669	#[test]
670	fn test_new() {
671		let t = Timestamp::new(5000, Timescale::MILLI).unwrap();
672		assert_eq!(t.value(), 5000);
673		assert_eq!(t.scale(), Timescale::MILLI);
674		assert_eq!(t.as_millis(), 5000);
675	}
676
677	#[test]
678	fn test_custom_scale_convert() {
679		// 120 units at 60Hz = 2 seconds, expressed at 1000Hz = 2000 ms.
680		let scale_60 = Timescale::new(60).unwrap();
681		let t = Timestamp::new(120, scale_60)
682			.unwrap()
683			.convert(Timescale::MILLI)
684			.unwrap();
685		assert_eq!(t.scale(), Timescale::MILLI);
686		assert_eq!(t.as_millis(), 2000);
687	}
688}