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