1use std::cell::RefCell;
15use std::collections::VecDeque;
16use std::rc::Rc;
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use dioxus::prelude::*;
20use serde::Serialize;
21use serde_json::{Value, json};
22
23use crate::ForgeClient;
24
25const DEFAULT_FLUSH_INTERVAL_MS: u32 = 5000;
26const DEFAULT_MAX_BATCH: usize = 20;
27const MAX_BREADCRUMBS: usize = 20;
28const MAX_QUEUE_SIZE: usize = 1000;
29#[cfg(target_arch = "wasm32")]
30const AUTO_CAPTURE_DELAY_MS: u64 = 2000;
31
32fn now_iso() -> String {
33 #[cfg(target_arch = "wasm32")]
34 {
35 js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default()
36 }
37 #[cfg(not(target_arch = "wasm32"))]
38 {
39 let dur = std::time::SystemTime::now()
41 .duration_since(std::time::UNIX_EPOCH)
42 .unwrap_or_default();
43 let secs = dur.as_secs();
44 let days = secs / 86400;
46 let time_of_day = secs % 86400;
47 let hours = time_of_day / 3600;
48 let minutes = (time_of_day % 3600) / 60;
49 let seconds = time_of_day % 60;
50 let (year, month, day) = days_to_date(days);
52 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
53 }
54}
55
56#[cfg(not(target_arch = "wasm32"))]
57fn days_to_date(days: u64) -> (u64, u64, u64) {
58 let z = days + 719468;
60 let era = z / 146097;
61 let doe = z - era * 146097;
62 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
63 let y = yoe + era * 400;
64 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
65 let mp = (5 * doy + 2) / 153;
66 let d = doy - (153 * mp + 2) / 5 + 1;
67 let m = if mp < 10 { mp + 3 } else { mp - 9 };
68 let y = if m <= 2 { y + 1 } else { y };
69 (y, m, d)
70}
71
72static CORRELATION_COUNTER: AtomicU64 = AtomicU64::new(0);
73
74fn generate_correlation_id() -> String {
76 let counter = CORRELATION_COUNTER.fetch_add(1, Ordering::Relaxed);
77 format!("dx-{counter}-{:08x}", rand_u32())
78}
79
80fn rand_u32() -> u32 {
81 #[cfg(target_arch = "wasm32")]
82 {
83 (js_sys::Math::random() * f64::from(u32::MAX)) as u32
84 }
85 #[cfg(not(target_arch = "wasm32"))]
86 {
87 use std::collections::hash_map::RandomState;
88 use std::hash::{BuildHasher, Hasher};
89 RandomState::new().build_hasher().finish() as u32
90 }
91}
92
93#[derive(Clone, Debug, PartialEq)]
95pub struct SignalsConfig {
96 pub enabled: bool,
98 pub auto_page_views: bool,
100 pub auto_capture_errors: bool,
102 pub flush_interval: u32,
104 pub max_batch_size: usize,
106}
107
108impl Default for SignalsConfig {
109 fn default() -> Self {
110 Self {
111 enabled: true,
112 auto_page_views: true,
113 auto_capture_errors: true,
114 flush_interval: DEFAULT_FLUSH_INTERVAL_MS,
115 max_batch_size: DEFAULT_MAX_BATCH,
116 }
117 }
118}
119
120#[derive(Clone, Serialize)]
121struct SignalEventPayload {
122 event: String,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 properties: Option<Value>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 correlation_id: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 timestamp: Option<String>,
129}
130
131#[derive(Clone, Serialize)]
132struct EventBatch {
133 events: Vec<SignalEventPayload>,
134 context: Option<BatchContext>,
135}
136
137#[derive(Clone, Serialize)]
138struct BatchContext {
139 #[serde(skip_serializing_if = "Option::is_none")]
140 page_url: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 session_id: Option<String>,
143}
144
145#[derive(Clone, Serialize)]
146struct DiagnosticPayload {
147 errors: Vec<ErrorPayload>,
148}
149
150#[derive(Clone, Serialize)]
151struct ErrorPayload {
152 message: String,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 stack: Option<String>,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 context: Option<Value>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 correlation_id: Option<String>,
159 #[serde(skip_serializing_if = "VecDeque::is_empty")]
160 breadcrumbs: VecDeque<BreadcrumbEntry>,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 page_url: Option<String>,
163}
164
165#[derive(Clone, Serialize)]
166struct BreadcrumbEntry {
167 message: String,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 data: Option<Value>,
170 timestamp: String,
171}
172
173struct SignalsInner {
174 client: ForgeClient,
175 config: SignalsConfig,
176 queue: Vec<SignalEventPayload>,
177 breadcrumbs: VecDeque<BreadcrumbEntry>,
178 session_id: Option<String>,
179 last_correlation_id: Option<String>,
180 utm_params: Option<Value>,
181 destroyed: bool,
182}
183
184#[derive(Clone)]
188pub struct ForgeSignals {
189 inner: Rc<RefCell<SignalsInner>>,
190}
191
192impl ForgeSignals {
193 pub fn new(client: ForgeClient, config: SignalsConfig) -> Self {
195 let utm_params = if config.enabled { extract_utm() } else { None };
196 Self {
197 inner: Rc::new(RefCell::new(SignalsInner {
198 client,
199 config,
200 queue: Vec::new(),
201 breadcrumbs: VecDeque::new(),
202 session_id: None,
203 last_correlation_id: None,
204 utm_params,
205 destroyed: false,
206 })),
207 }
208 }
209
210 pub fn track(&self, event: &str, properties: Value) {
212 let inner = self.inner.borrow();
213 if !inner.config.enabled { return; }
214 drop(inner);
215
216 let correlation_id = self.inner.borrow().last_correlation_id.clone();
217 let payload = SignalEventPayload {
218 event: event.to_string(),
219 properties: Some(properties),
220 correlation_id,
221 timestamp: Some(now_iso()),
222 };
223 let mut inner = self.inner.borrow_mut();
224 inner.queue.push(payload);
225 let should_flush = inner.queue.len() >= inner.config.max_batch_size;
226 drop(inner);
227 if should_flush {
228 let this = self.clone();
229 spawn(async move { this.flush().await; });
230 }
231 }
232
233 pub async fn identify(&self, user_id: &str, traits: Value) {
235 let (url, session_id) = {
236 let inner = self.inner.borrow();
237 if !inner.config.enabled { return; }
238 (inner.client.get_url().to_string(), inner.session_id.clone())
239 };
240
241 let body = json!({ "user_id": user_id, "traits": traits });
242 let _ = post_signal(&url, "signal/user", &body, session_id.as_deref()).await;
243 }
244
245 pub async fn page(&self, url_path: &str) {
247 let (base_url, session_id, utm) = {
248 let mut inner = self.inner.borrow_mut();
249 if !inner.config.enabled { return; }
250 let utm = inner.utm_params.take();
251 (inner.client.get_url().to_string(), inner.session_id.clone(), utm)
252 };
253
254 let mut payload = json!({ "url": url_path });
255 if let Some(utm_val) = utm
256 && let (Some(target), Some(source)) = (payload.as_object_mut(), utm_val.as_object())
257 {
258 for (k, v) in source {
259 target.insert(k.clone(), v.clone());
260 }
261 }
262
263 if let Ok(resp) = post_signal(&base_url, "signal/view", &payload, session_id.as_deref()).await
264 && let Some(sid) = resp.get("session_id").and_then(|v| v.as_str())
265 {
266 self.inner.borrow_mut().session_id = Some(sid.to_string());
267 }
268 }
269
270 pub async fn capture_error(&self, message: &str, context: Value) {
272 let (url, session_id, correlation_id, breadcrumbs, page_url) = {
273 let inner = self.inner.borrow();
274 if !inner.config.enabled { return; }
275 (
276 inner.client.get_url().to_string(),
277 inner.session_id.clone(),
278 inner.last_correlation_id.clone(),
279 inner.breadcrumbs.clone(),
280 current_page_url(),
281 )
282 };
283
284 let body = DiagnosticPayload {
285 errors: vec![ErrorPayload {
286 message: message.to_string(),
287 stack: None,
288 context: Some(context),
289 correlation_id,
290 breadcrumbs,
291 page_url,
292 }],
293 };
294 let _ = post_signal(
295 &url,
296 "signal/report",
297 &serde_json::to_value(&body).unwrap_or_default(),
298 session_id.as_deref(),
299 )
300 .await;
301 }
302
303 pub fn breadcrumb(&self, message: &str, data: Option<Value>) {
305 let mut inner = self.inner.borrow_mut();
306 if !inner.config.enabled { return; }
307 inner.breadcrumbs.push_back(BreadcrumbEntry {
308 message: message.to_string(),
309 data,
310 timestamp: now_iso(),
311 });
312 if inner.breadcrumbs.len() > MAX_BREADCRUMBS {
313 inner.breadcrumbs.pop_front();
314 }
315 }
316
317 pub fn next_correlation_id(&self) -> String {
319 let id = generate_correlation_id();
320 self.inner.borrow_mut().last_correlation_id = Some(id.clone());
321 id
322 }
323
324 #[must_use]
325 pub fn get_session_id(&self) -> Option<String> {
326 self.inner.borrow().session_id.clone()
327 }
328
329 pub async fn flush(&self) {
331 let (url, mut events, session_id) = {
332 let mut inner = self.inner.borrow_mut();
333 if inner.queue.is_empty() { return; }
334 let max = inner.config.max_batch_size;
335 let count = inner.queue.len().min(max);
336 let events: Vec<_> = inner.queue.drain(..count).collect();
337 (inner.client.get_url().to_string(), events, inner.session_id.clone())
338 };
339
340 let batch = EventBatch {
341 events: events.clone(),
342 context: Some(BatchContext {
343 page_url: current_page_url(),
344 session_id: session_id.clone(),
345 }),
346 };
347
348 match post_signal(
349 &url,
350 "signal/event",
351 &serde_json::to_value(&batch).unwrap_or_default(),
352 session_id.as_deref(),
353 )
354 .await
355 {
356 Ok(resp) => {
357 if let Some(sid) = resp.get("session_id").and_then(|v| v.as_str()) {
358 self.inner.borrow_mut().session_id = Some(sid.to_string());
359 }
360 }
361 Err(()) => {
362 let mut inner = self.inner.borrow_mut();
363 events.extend(inner.queue.drain(..));
364 events.truncate(MAX_QUEUE_SIZE);
365 inner.queue = events;
366 }
367 }
368 }
369
370 pub fn destroy(&self) {
372 self.inner.borrow_mut().destroyed = true;
373 flush_beacon(self);
374 }
375
376 #[must_use]
377 pub fn auto_page_views(&self) -> bool {
378 let inner = self.inner.borrow();
379 inner.config.enabled && inner.config.auto_page_views
380 }
381
382 #[must_use]
383 pub fn auto_capture_errors(&self) -> bool {
384 let inner = self.inner.borrow();
385 inner.config.enabled && inner.config.auto_capture_errors
386 }
387
388 #[must_use]
389 pub fn flush_interval(&self) -> u32 {
390 self.inner.borrow().config.flush_interval
391 }
392
393 #[must_use]
394 pub fn is_destroyed(&self) -> bool {
395 self.inner.borrow().destroyed
396 }
397
398 #[must_use]
399 pub fn is_enabled(&self) -> bool {
400 self.inner.borrow().config.enabled
401 }
402}
403
404fn flush_beacon(signals: &ForgeSignals) {
406 let (url, events, session_id) = {
407 let mut inner = signals.inner.borrow_mut();
408 if inner.queue.is_empty() { return; }
409 let events = std::mem::take(&mut inner.queue);
410 (inner.client.get_url().to_string(), events, inner.session_id.clone())
411 };
412
413 let batch = EventBatch {
414 events,
415 context: Some(BatchContext {
416 page_url: current_page_url(),
417 session_id,
418 }),
419 };
420
421 let body = serde_json::to_string(&batch).unwrap_or_default();
422
423 #[cfg(target_arch = "wasm32")]
424 {
425 let url = format!("{url}/_api/signal/event");
426 if let Some(navigator) = web_sys::window().map(|w| w.navigator()) {
427 let _ = navigator.send_beacon_with_opt_str(&url, Some(&body));
428 }
429 }
430
431 #[cfg(not(target_arch = "wasm32"))]
432 {
433 let _ = (url, body);
434 }
435}
436
437fn current_page_url() -> Option<String> {
439 #[cfg(target_arch = "wasm32")]
440 {
441 web_sys::window()
442 .and_then(|w| w.location().href().ok())
443 }
444 #[cfg(not(target_arch = "wasm32"))]
445 {
446 None
447 }
448}
449
450fn extract_utm() -> Option<Value> {
452 #[cfg(target_arch = "wasm32")]
453 {
454 let search = web_sys::window()?.location().search().ok()?;
455 let params = web_sys::UrlSearchParams::new_with_str(&search).ok()?;
456 let mut utm = serde_json::Map::new();
457 for key in &["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"] {
458 if let Some(val) = params.get(key) {
459 utm.insert(key.to_string(), Value::String(val));
460 }
461 }
462 if utm.is_empty() { None } else { Some(Value::Object(utm)) }
463 }
464 #[cfg(not(target_arch = "wasm32"))]
465 {
466 None
467 }
468}
469
470pub(crate) fn platform_tag() -> &'static str {
473 #[cfg(target_arch = "wasm32")]
474 { "web" }
475
476 #[cfg(not(target_arch = "wasm32"))]
477 {
478 #[cfg(target_os = "macos")]
479 { "desktop-macos" }
480 #[cfg(target_os = "ios")]
481 { "ios" }
482 #[cfg(target_os = "android")]
483 { "android" }
484 #[cfg(target_os = "windows")]
485 { "desktop-windows" }
486 #[cfg(target_os = "linux")]
487 { "desktop-linux" }
488 #[cfg(not(any(
489 target_os = "macos",
490 target_os = "ios",
491 target_os = "android",
492 target_os = "windows",
493 target_os = "linux",
494 )))]
495 { "desktop" }
496 }
497}
498
499async fn post_signal(
501 base_url: &str,
502 path: &str,
503 body: &Value,
504 session_id: Option<&str>,
505) -> Result<Value, ()> {
506 #[cfg(target_arch = "wasm32")]
507 {
508 use gloo_net::http::Request;
509 let mut req = Request::post(&format!("{base_url}/_api/{path}"))
510 .header("Content-Type", "application/json")
511 .header("x-forge-platform", platform_tag());
512 if let Some(sid) = session_id {
513 req = req.header("x-session-id", sid);
514 }
515 let resp = req.body(body.to_string()).map_err(|_| ())?.send().await.map_err(|_| ())?;
516 resp.json().await.map_err(|_| ())
517 }
518 #[cfg(not(target_arch = "wasm32"))]
519 {
520 use reqwest::Client;
521 let mut req = Client::new()
522 .post(format!("{base_url}/_api/{path}"))
523 .header("x-forge-platform", platform_tag())
524 .json(body);
525 if let Some(sid) = session_id {
526 req = req.header("x-session-id", sid);
527 }
528 let resp = req.send().await.map_err(|_| ())?;
529 resp.json().await.map_err(|_| ())
530 }
531}
532
533pub fn use_signals() -> ForgeSignals {
535 use_context::<ForgeSignals>()
536}
537
538#[cfg(target_arch = "wasm32")]
541pub(crate) fn setup_auto_capture(signals: ForgeSignals) {
542 use wasm_bindgen::closure::Closure;
543 use wasm_bindgen::JsCast;
544
545 if !signals.is_enabled() { return; }
546
547 let flush_interval = signals.flush_interval();
548
549 {
551 let signals = signals.clone();
552 spawn(async move {
553 loop {
554 gloo_timers::future::sleep(std::time::Duration::from_millis(u64::from(flush_interval))).await;
555 if signals.is_destroyed() { break; }
556 signals.flush().await;
557 }
558 });
559 }
560
561 {
563 let signals = signals.clone();
564 spawn(async move {
565 gloo_timers::future::sleep(std::time::Duration::from_millis(AUTO_CAPTURE_DELAY_MS)).await;
566 if signals.is_destroyed() { return; }
567
568 let window = match web_sys::window() {
569 Some(w) => w,
570 None => return,
571 };
572
573 if signals.auto_page_views() {
575 let path = window.location().pathname().unwrap_or_else(|_| "/".to_string());
576 let signals_page = signals.clone();
577 spawn(async move { signals_page.page(&path).await; });
578
579 {
581 let signals = signals.clone();
582 let closure = Closure::<dyn Fn()>::new(move || {
583 let path = web_sys::window()
584 .and_then(|w| w.location().pathname().ok())
585 .unwrap_or_else(|| "/".to_string());
586 let signals = signals.clone();
587 spawn(async move { signals.page(&path).await; });
588 });
589 let _ = window.add_event_listener_with_callback(
590 "forge-pushstate",
591 closure.as_ref().unchecked_ref(),
592 );
593 let _ = window.add_event_listener_with_callback(
594 "popstate",
595 closure.as_ref().unchecked_ref(),
596 );
597 closure.forget();
599 }
600
601 let patch_js = r#"
604 (function() {
605 var origPush = history.pushState;
606 var origReplace = history.replaceState;
607 history.pushState = function() {
608 var before = location.href;
609 origPush.apply(this, arguments);
610 if (location.href !== before) {
611 window.dispatchEvent(new Event('forge-pushstate'));
612 }
613 };
614 history.replaceState = function() {
615 var before = location.href;
616 origReplace.apply(this, arguments);
617 if (location.href !== before) {
618 window.dispatchEvent(new Event('forge-pushstate'));
619 }
620 };
621 })()
622 "#;
623 let _ = js_sys::eval(patch_js);
624 }
625
626 if signals.auto_capture_errors() {
628 {
630 let signals = signals.clone();
631 let closure = Closure::<dyn Fn(web_sys::ErrorEvent)>::new(move |e: web_sys::ErrorEvent| {
632 let msg = e.message();
633 if msg.is_empty() { return; }
634 let signals = signals.clone();
635 spawn(async move { signals.capture_error(&msg, json!({})).await; });
636 });
637 let _ = window.add_event_listener_with_callback(
638 "error",
639 closure.as_ref().unchecked_ref(),
640 );
641 closure.forget();
643 }
644
645 {
646 let signals = signals.clone();
647 let closure = Closure::<dyn Fn(web_sys::PromiseRejectionEvent)>::new(
648 move |e: web_sys::PromiseRejectionEvent| {
649 let reason = e.reason();
650 let msg = reason.as_string().unwrap_or_else(|| "Unhandled promise rejection".to_string());
651 let signals = signals.clone();
652 spawn(async move { signals.capture_error(&msg, json!({})).await; });
653 },
654 );
655 let _ = window.add_event_listener_with_callback(
656 "unhandledrejection",
657 closure.as_ref().unchecked_ref(),
658 );
659 closure.forget();
660 }
661 }
662
663 {
665 let signals = signals.clone();
666 let closure = Closure::<dyn Fn()>::new(move || {
667 if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
668 if doc.visibility_state() == web_sys::VisibilityState::Hidden {
669 flush_beacon(&signals);
670 }
671 }
672 });
673 if let Some(doc) = window.document() {
674 let _ = doc.add_event_listener_with_callback(
675 "visibilitychange",
676 closure.as_ref().unchecked_ref(),
677 );
678 }
679 closure.forget();
680 }
681 });
682 }
683}
684
685#[cfg(not(target_arch = "wasm32"))]
686pub(crate) fn setup_auto_capture(signals: ForgeSignals) {
687 if !signals.is_enabled() { return; }
688
689 let flush_interval = signals.flush_interval();
690
691 {
693 let signals = signals.clone();
694 spawn(async move {
695 let mut interval = tokio::time::interval(
696 std::time::Duration::from_millis(u64::from(flush_interval)),
697 );
698 loop {
699 interval.tick().await;
700 if signals.is_destroyed() { break; }
701 signals.flush().await;
702 }
703 });
704 }
705
706 if signals.auto_capture_errors() {
710 let base_url = {
711 let inner = signals.inner.borrow();
712 inner.client.get_url().to_string()
713 };
714 let prev = std::panic::take_hook();
715 std::panic::set_hook(Box::new(move |info| {
716 let msg = info
717 .payload()
718 .downcast_ref::<&str>()
719 .copied()
720 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
721 .unwrap_or("panic");
722 let location = info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()));
723 let url = format!("{}/_api/signal/report", base_url);
724 let body = serde_json::json!({
725 "errors": [{
726 "message": msg,
727 "context": { "location": location, "kind": "panic" },
728 }]
729 });
730 let _ = std::thread::spawn(move || {
733 let rt = tokio::runtime::Builder::new_current_thread()
734 .enable_all()
735 .build();
736 if let Ok(rt) = rt {
737 let _ = rt.block_on(async {
738 let _ = reqwest::Client::new()
739 .post(&url)
740 .header("x-forge-platform", platform_tag())
741 .json(&body)
742 .send()
743 .await;
744 });
745 }
746 });
747 prev(info);
748 }));
749 }
750}
751
752#[cfg(test)]
753#[allow(clippy::unwrap_used)]
754mod tests {
755 use super::*;
756
757 #[test]
758 fn signals_config_defaults() {
759 let config = SignalsConfig::default();
760 assert!(config.enabled);
761 assert!(config.auto_page_views);
762 assert!(config.auto_capture_errors);
763 assert_eq!(config.flush_interval, 5000);
764 assert_eq!(config.max_batch_size, 20);
765 }
766
767 #[test]
768 fn correlation_id_format() {
769 let id = generate_correlation_id();
770 let parts: Vec<&str> = id.split('-').collect();
771 assert_eq!(parts.first().copied(), Some("dx"));
772 assert!(parts.get(1).unwrap().parse::<u64>().is_ok());
773 let hex_part = parts.get(2).unwrap();
774 assert_eq!(hex_part.len(), 8);
775 assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
776 }
777
778 #[test]
779 fn correlation_ids_are_unique() {
780 let a = generate_correlation_id();
781 let b = generate_correlation_id();
782 assert_ne!(a, b);
783 }
784
785 #[test]
786 fn platform_tag_returns_expected_value() {
787 let tag = platform_tag();
788 let known = [
789 "web",
790 "desktop-macos",
791 "desktop-linux",
792 "desktop-windows",
793 "ios",
794 "android",
795 "desktop",
796 ];
797 assert!(
798 known.contains(&tag),
799 "unexpected platform tag: {tag}",
800 );
801 }
802
803 #[test]
804 fn now_iso_produces_valid_format() {
805 let ts = now_iso();
806 assert_eq!(ts.len(), 20, "unexpected length for timestamp: {ts}");
808 assert_eq!(ts.as_bytes().get(4).copied(), Some(b'-'));
809 assert_eq!(ts.as_bytes().get(7).copied(), Some(b'-'));
810 assert_eq!(ts.as_bytes().get(10).copied(), Some(b'T'));
811 assert_eq!(ts.as_bytes().get(13).copied(), Some(b':'));
812 assert_eq!(ts.as_bytes().get(16).copied(), Some(b':'));
813 assert_eq!(ts.as_bytes().get(19).copied(), Some(b'Z'));
814 }
815
816 #[test]
817 fn days_to_date_epoch_start() {
818 assert_eq!(days_to_date(0), (1970, 1, 1));
819 }
820
821 #[test]
822 fn days_to_date_known_date() {
823 assert_eq!(days_to_date(19810), (2024, 3, 28));
825 }
826
827 #[test]
828 fn days_to_date_leap_year() {
829 assert_eq!(days_to_date(11016), (2000, 2, 29));
831 }
832
833 #[test]
834 fn days_to_date_century_non_leap() {
835 assert_eq!(days_to_date(47541), (2100, 3, 1));
837 }
838
839 #[test]
840 fn now_iso_is_recent() {
841 let ts = now_iso();
842 let year: u32 = ts.get(..4).unwrap().parse().unwrap();
843 assert!(year >= 2025, "expected year >= 2025, got {year}");
844 }
845}