1use std::sync::atomic::{AtomicU64, Ordering};
27
28use chrono::{DateTime, Utc};
29
30use crate::events::GameEvent;
31use crate::log::entry::LogEntry;
32use crate::log::timestamp::parse_log_timestamp;
33use crate::parsers;
34use crate::util::truncate_for_log;
35
36#[derive(Debug, Default)]
47pub struct RouterStats {
48 routed: AtomicU64,
50 unknown: AtomicU64,
52 timestamp_failures: AtomicU64,
54}
55
56impl RouterStats {
57 pub fn new() -> Self {
59 Self::default()
60 }
61
62 pub fn routed_count(&self) -> u64 {
64 self.routed.load(Ordering::Relaxed)
65 }
66
67 pub fn unknown_count(&self) -> u64 {
69 self.unknown.load(Ordering::Relaxed)
70 }
71
72 pub fn timestamp_failure_count(&self) -> u64 {
74 self.timestamp_failures.load(Ordering::Relaxed)
75 }
76
77 pub fn reset(&self) {
79 self.routed.store(0, Ordering::Relaxed);
80 self.unknown.store(0, Ordering::Relaxed);
81 self.timestamp_failures.store(0, Ordering::Relaxed);
82 }
83}
84
85pub struct Router {
113 stats: RouterStats,
115}
116
117impl Router {
118 pub fn new() -> Self {
120 Self {
121 stats: RouterStats::new(),
122 }
123 }
124
125 pub fn stats(&self) -> &RouterStats {
127 &self.stats
128 }
129
130 pub fn route(&self, entry: &LogEntry) -> Vec<GameEvent> {
145 let timestamp = extract_timestamp(&entry.body);
146
147 if timestamp.is_none() {
148 self.stats
149 .timestamp_failures
150 .fetch_add(1, Ordering::Relaxed);
151 ::log::debug!(
152 "No timestamp in entry header: {:?}",
153 truncate_for_log(&entry.body, 120),
154 );
155 }
156
157 let events = dispatch_to_parsers(entry, timestamp);
158
159 if events.is_empty() {
160 self.stats.unknown.fetch_add(1, Ordering::Relaxed);
161 ::log::debug!(
162 "Unrecognized entry (header={}, body={:?})",
163 entry.header,
164 truncate_for_log(&entry.body, 120),
165 );
166 } else {
167 self.stats.routed.fetch_add(1, Ordering::Relaxed);
168 }
169
170 events
171 }
172}
173
174impl Default for Router {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180fn extract_timestamp(body: &str) -> Option<DateTime<Utc>> {
200 let first_line = body.lines().next()?;
201
202 let after_bracket = first_line.find(']').map(|pos| &first_line[pos + 1..])?;
204 let trimmed = after_bracket.trim();
205
206 if trimmed.is_empty() {
207 return None;
208 }
209
210 let words: Vec<&str> = trimmed.split_whitespace().collect();
214
215 let max_words = words.len().min(4);
218 for end in (2..=max_words).rev() {
219 let candidate = words[..end].join(" ");
220 let cleaned = candidate.trim_end_matches(|c: char| c.is_ascii_punctuation());
222 if let Ok(ts) = parse_log_timestamp(cleaned) {
223 return Some(ts);
224 }
225 }
226
227 None
228}
229
230fn dispatch_to_parsers(entry: &LogEntry, timestamp: Option<DateTime<Utc>>) -> Vec<GameEvent> {
257 if let Some(event) = parsers::metadata::try_parse(entry, timestamp) {
259 return vec![event];
260 }
261
262 if let Some(event) = parsers::truncation::try_parse(entry, timestamp) {
267 return vec![event];
268 }
269
270 let gre_events = parsers::gre::try_parse(entry, timestamp);
272 if !gre_events.is_empty() {
273 return gre_events;
274 }
275
276 let event = None
278 .or_else(|| parsers::client_actions::try_parse(entry, timestamp))
279 .or_else(|| parsers::match_state::try_parse(entry, timestamp))
280 .or_else(|| parsers::session::try_parse(entry, timestamp))
281 .or_else(|| parsers::draft::bot::try_parse(entry, timestamp))
282 .or_else(|| parsers::draft::human::try_parse(entry, timestamp))
283 .or_else(|| parsers::draft::complete::try_parse(entry, timestamp))
284 .or_else(|| parsers::event_lifecycle::try_parse(entry, timestamp))
285 .or_else(|| parsers::rank::try_parse(entry, timestamp))
286 .or_else(|| parsers::deck_collection::try_parse(entry, timestamp))
287 .or_else(|| parsers::inventory::try_parse(entry, timestamp))
288 .or_else(|| parsers::connection_state::try_parse(entry, timestamp))
289 .or_else(|| parsers::connection_close::try_parse(entry, timestamp))
290 .or_else(|| parsers::connection_error::try_parse(entry, timestamp));
291
292 event.into_iter().collect()
293}
294
295#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::log::entry::EntryHeader;
303 use chrono::Timelike;
304
305 fn unity_entry(body: &str) -> LogEntry {
307 LogEntry {
308 header: EntryHeader::UnityCrossThreadLogger,
309 body: body.to_owned(),
310 }
311 }
312
313 mod extract_timestamp_tests {
316 use super::*;
317
318 #[test]
319 fn test_extract_timestamp_us_format_with_pm() {
320 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent";
321 let ts = extract_timestamp(body);
322 assert!(ts.is_some());
323 if let Some(ts) = ts {
324 assert_eq!(
325 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
326 "2026-02-25 12:00:00"
327 );
328 }
329 }
330
331 #[test]
332 fn test_extract_timestamp_us_format_with_am() {
333 let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM";
334 let ts = extract_timestamp(body);
335 assert!(ts.is_some());
336 if let Some(ts) = ts {
337 assert_eq!(
338 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
339 "2026-02-22 11:59:51"
340 );
341 }
342 }
343
344 #[test]
345 fn test_extract_timestamp_with_trailing_colon() {
346 let body = "[UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to AAF4FC69CE47D53A";
347 let ts = extract_timestamp(body);
348 assert!(ts.is_some());
349 if let Some(ts) = ts {
350 assert_eq!(ts.hour(), 23); }
352 }
353
354 #[test]
355 fn test_extract_timestamp_24h_format() {
356 let body = "[UnityCrossThreadLogger]2026-02-25 14:30:00 some content";
357 let ts = extract_timestamp(body);
358 assert!(ts.is_some());
359 if let Some(ts) = ts {
360 assert_eq!(
361 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
362 "2026-02-25 14:30:00"
363 );
364 }
365 }
366
367 #[test]
368 fn test_extract_timestamp_no_bracket_returns_none() {
369 let body = "no bracket here";
370 let ts = extract_timestamp(body);
371 assert!(ts.is_none());
372 }
373
374 #[test]
375 fn test_extract_timestamp_empty_after_bracket_returns_none() {
376 let body = "[UnityCrossThreadLogger]";
377 let ts = extract_timestamp(body);
378 assert!(ts.is_none());
379 }
380
381 #[test]
382 fn test_extract_timestamp_no_timestamp_content_returns_none() {
383 let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
384 let ts = extract_timestamp(body);
385 assert!(ts.is_none());
386 }
387
388 #[test]
389 fn test_extract_timestamp_timestamp_on_own_line() {
390 let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n<== StartHook(abc-123)";
391 let ts = extract_timestamp(body);
392 assert!(ts.is_some());
393 if let Some(ts) = ts {
394 assert_eq!(
395 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
396 "2026-02-22 11:59:51"
397 );
398 }
399 }
400
401 #[test]
402 fn test_extract_timestamp_with_leading_space() {
403 let body = "[UnityCrossThreadLogger] 2/25/2026 12:00:00 PM event";
404 let ts = extract_timestamp(body);
405 assert!(ts.is_some());
406 }
407 }
408
409 mod known_routing {
412 use super::*;
413
414 #[test]
415 fn test_route_gre_game_state_message() {
416 let router = Router::new();
417 let payload = serde_json::json!({
418 "greToClientEvent": {
419 "greToClientMessages": [{
420 "type": "GREMessageType_GameStateMessage",
421 "gameStateMessage": {
422 "gameInfo": { "stage": "GameStage_Play" },
423 "gameObjects": [],
424 "zones": []
425 }
426 }]
427 }
428 });
429 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
430 let entry = unity_entry(&body);
431
432 let results = router.route(&entry);
433 assert_eq!(results.len(), 1);
434 assert!(matches!(&results[0], GameEvent::GameState(_)));
435 assert_eq!(router.stats().routed_count(), 1);
436 assert_eq!(router.stats().unknown_count(), 0);
437 }
438
439 #[test]
440 fn test_route_client_action() {
441 let router = Router::new();
442 let payload = serde_json::json!({
443 "clientToMatchServiceMessageType":
444 "ClientToMatchServiceMessageType_ClientToGREMessage",
445 "payload": {
446 "type": "ClientMessageType_MulliganResp",
447 "gameStateId": 5,
448 "respId": 1,
449 "mulliganResp": { "decision": "MulliganOption_Mulligan" }
450 },
451 "requestId": 12345,
452 "timestamp": "637123456789"
453 });
454 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
455 let entry = unity_entry(&body);
456
457 let results = router.route(&entry);
458 assert_eq!(results.len(), 1);
459 assert!(matches!(&results[0], GameEvent::ClientAction(_)));
460 }
461
462 #[test]
463 fn test_route_match_state() {
464 let router = Router::new();
465 let payload = serde_json::json!({
466 "matchGameRoomStateChangedEvent": {
467 "gameRoomInfo": {
468 "stateType": "MatchGameRoomStateType_Playing",
469 "gameRoomConfig": {
470 "matchId": "match-123",
471 "reservedPlayers": []
472 }
473 }
474 }
475 });
476 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
477 let entry = unity_entry(&body);
478
479 let results = router.route(&entry);
480 assert_eq!(results.len(), 1);
481 assert!(matches!(&results[0], GameEvent::MatchState(_)));
482 }
483
484 #[test]
485 fn test_route_session_authenticate_response() {
486 let router = Router::new();
487 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
488 {\"screenName\":\"TestPlayer\"}";
489 let entry = unity_entry(body);
490
491 let results = router.route(&entry);
492 assert_eq!(results.len(), 1);
493 assert!(matches!(&results[0], GameEvent::Session(_)));
494 }
495
496 #[test]
497 fn test_route_rank_event() {
498 let router = Router::new();
499 let payload = serde_json::json!({
500 "constructedClass": "Gold",
501 "constructedLevel": 2,
502 "limitedClass": "Silver",
503 "limitedLevel": 1
504 });
505 let body = format!(
506 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
507 <== RankGetCombinedRankInfo(abc-123)\n{payload}",
508 );
509 let entry = unity_entry(&body);
510
511 let results = router.route(&entry);
512 assert_eq!(results.len(), 1);
513 assert!(matches!(&results[0], GameEvent::Rank(_)));
514 }
515
516 #[test]
517 fn test_route_event_lifecycle() {
518 let router = Router::new();
519 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
520 ==> EventJoin {\"id\":\"abc-123\",\
521 \"request\":\"{\\\"EventName\\\":\\\"PremierDraft_MKM\\\"}\"}";
522 let entry = unity_entry(body);
523
524 let results = router.route(&entry);
525 assert_eq!(results.len(), 1);
526 assert!(matches!(&results[0], GameEvent::EventLifecycle(_)));
527 }
528
529 #[test]
530 fn test_route_draft_complete() {
531 let router = Router::new();
532 let payload = serde_json::json!({
533 "CourseId": "draft-123",
534 "InternalEventName": "PremierDraft_MKM",
535 "CardPool": [12345, 67890]
536 });
537 let body = format!(
538 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
539 <== DraftCompleteDraft(abc-123)\n{payload}",
540 );
541 let entry = unity_entry(&body);
542
543 let results = router.route(&entry);
544 assert_eq!(results.len(), 1);
545 assert!(matches!(&results[0], GameEvent::DraftComplete(_)));
546 }
547
548 #[test]
549 fn test_route_draft_bot_pack_presentation() {
550 let router = Router::new();
551 let payload = serde_json::json!({
552 "CurrentModule": "BotDraft",
553 "Payload":"{\"DraftStatus\":\"PickNext\",\"PackNumber\":0,\"PickNumber\":0,\"DraftPack\":[\"12345\",\"67890\",\"11111\"]}"
554 });
555 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n<== BotDraftDraftStatus(uuid)\n{payload}",);
556 let entry = unity_entry(&body);
557
558 let results = router.route(&entry);
559 assert_eq!(results.len(), 1);
560 assert!(matches!(&results[0], GameEvent::DraftBot(_)));
561 }
562
563 #[test]
564 fn test_route_draft_human_notify() {
565 let router = Router::new();
566 let payload = serde_json::json!({
567 "draftId": "abc-123-def",
568 "SelfPack": 0,
569 "SelfPick": 0,
570 "PackCards": "12345,67890,11111"
571 });
572 let body = format!("[UnityCrossThreadLogger]Draft.Notify\n{payload}",);
573 let entry = unity_entry(&body);
574
575 let results = router.route(&entry);
576 assert_eq!(results.len(), 1);
577 assert!(matches!(&results[0], GameEvent::DraftHuman(_)));
578 }
579
580 #[test]
581 fn test_route_start_hook_with_additional_fields_routes_to_inventory() {
582 let router = Router::new();
583 let payload = serde_json::json!({
584 "InventoryInfo": { "Gems": 100 },
585 "DeckSummariesV2": []
586 });
587 let body = format!(
588 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
589 <== StartHook(abc-123)\n{payload}",
590 );
591 let entry = unity_entry(&body);
592
593 let results = router.route(&entry);
594 assert_eq!(results.len(), 1);
595 assert!(matches!(&results[0], GameEvent::Inventory(_)));
596 }
597
598 #[test]
599 fn test_route_inventory_event() {
600 let router = Router::new();
601 let payload = serde_json::json!({
602 "InventoryInfo": { "Gems": 100, "Gold": 5000 }
603 });
604 let body = format!(
605 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
606 <== StartHook(abc-123)\n{payload}",
607 );
608 let entry = unity_entry(&body);
609
610 let results = router.route(&entry);
611 assert_eq!(results.len(), 1);
612 assert!(matches!(&results[0], GameEvent::Inventory(_)));
613 }
614 }
615
616 mod unknown_entries {
619 use super::*;
620
621 #[test]
622 fn test_route_unknown_entry_returns_empty() {
623 let router = Router::new();
624 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
625 some unrecognized content here";
626 let entry = unity_entry(body);
627
628 let results = router.route(&entry);
629 assert!(results.is_empty());
630 }
631
632 #[test]
633 fn test_route_unknown_entry_increments_counter() {
634 let router = Router::new();
635 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
636 unrecognized content";
637 let entry = unity_entry(body);
638
639 router.route(&entry);
640 assert_eq!(router.stats().unknown_count(), 1);
641 assert_eq!(router.stats().routed_count(), 0);
642 }
643
644 #[test]
645 fn test_route_multiple_unknown_entries_accumulates() {
646 let router = Router::new();
647
648 for i in 0..5 {
649 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown_{i}",);
650 let entry = unity_entry(&body);
651 router.route(&entry);
652 }
653
654 assert_eq!(router.stats().unknown_count(), 5);
655 assert_eq!(router.stats().routed_count(), 0);
656 }
657
658 #[test]
659 fn test_route_empty_body_after_header_returns_empty() {
660 let router = Router::new();
661 let body = "[UnityCrossThreadLogger]";
662 let entry = unity_entry(body);
663
664 let results = router.route(&entry);
665 assert!(results.is_empty());
667 assert_eq!(router.stats().timestamp_failure_count(), 1);
668 assert_eq!(router.stats().unknown_count(), 1);
669 }
670
671 #[test]
672 fn test_route_no_timestamp_increments_timestamp_failure() {
673 let router = Router::new();
674 let body = "[UnityCrossThreadLogger]just some text without a timestamp";
675 let entry = unity_entry(body);
676
677 let results = router.route(&entry);
678 assert!(results.is_empty());
680 assert_eq!(router.stats().timestamp_failure_count(), 1);
681 assert_eq!(router.stats().unknown_count(), 1);
682 }
683
684 #[test]
685 fn test_route_no_timestamp_session_still_routes() {
686 let router = Router::new();
687 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
689 {\"screenName\":\"Player\"}";
690 let entry = unity_entry(body);
691
692 let results = router.route(&entry);
693 assert_eq!(results.len(), 1);
694 assert!(matches!(&results[0], GameEvent::Session(_)));
696 assert_eq!(router.stats().timestamp_failure_count(), 1);
697 assert_eq!(router.stats().routed_count(), 1);
698 }
699
700 #[test]
701 fn test_route_no_timestamp_passes_none_to_metadata() {
702 let router = Router::new();
703 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
706 {\"screenName\":\"Player\"}";
707 let entry = unity_entry(body);
708
709 let results = router.route(&entry);
710 assert_eq!(results.len(), 1);
711 assert!(
712 results[0].metadata().timestamp().is_none(),
713 "entries without parseable timestamps should have None timestamp"
714 );
715 }
716
717 #[test]
718 fn test_route_with_timestamp_passes_some_to_metadata() {
719 let router = Router::new();
720 let payload = serde_json::json!({
721 "greToClientEvent": {
722 "greToClientMessages": [{
723 "type": "GREMessageType_GameStateMessage",
724 "gameStateMessage": {
725 "gameInfo": { "stage": "GameStage_Play" },
726 "gameObjects": [],
727 "zones": []
728 }
729 }]
730 }
731 });
732 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
733 let entry = unity_entry(&body);
734
735 let results = router.route(&entry);
736 assert_eq!(results.len(), 1);
737 assert!(
738 results[0].metadata().timestamp().is_some(),
739 "entries with parseable timestamps should have Some timestamp"
740 );
741 }
742 }
743
744 mod stats {
747 use super::*;
748
749 #[test]
750 fn test_stats_initial_values_are_zero() {
751 let router = Router::new();
752 assert_eq!(router.stats().routed_count(), 0);
753 assert_eq!(router.stats().unknown_count(), 0);
754 assert_eq!(router.stats().timestamp_failure_count(), 0);
755 }
756
757 #[test]
758 fn test_stats_reset_clears_all_counters() {
759 let router = Router::new();
760
761 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
763 let entry = unity_entry(body);
764 router.route(&entry);
765 router.route(&entry);
766
767 assert_eq!(router.stats().unknown_count(), 2);
768
769 router.stats().reset();
770
771 assert_eq!(router.stats().routed_count(), 0);
772 assert_eq!(router.stats().unknown_count(), 0);
773 assert_eq!(router.stats().timestamp_failure_count(), 0);
774 }
775
776 #[test]
777 fn test_stats_mixed_routing() {
778 let router = Router::new();
779
780 let known_body = "[UnityCrossThreadLogger]authenticateResponse\n\
782 {\"screenName\":\"Player\"}";
783 router.route(&unity_entry(known_body));
784
785 let unknown_body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
787 router.route(&unity_entry(unknown_body));
788
789 let bad_ts_body = "[UnityCrossThreadLogger]";
791 router.route(&unity_entry(bad_ts_body));
792
793 assert_eq!(router.stats().routed_count(), 1);
794 assert_eq!(router.stats().unknown_count(), 2);
796 assert_eq!(router.stats().timestamp_failure_count(), 2);
798 }
799 }
800
801 mod default_impl {
804 use super::*;
805
806 #[test]
807 fn test_router_default_creates_functional_router() {
808 let router = Router::default();
809 assert_eq!(router.stats().routed_count(), 0);
810 assert_eq!(router.stats().unknown_count(), 0);
811 }
812 }
813
814 mod metadata_entries {
817 use super::*;
818
819 fn metadata_entry(body: &str) -> LogEntry {
821 LogEntry {
822 header: EntryHeader::Metadata,
823 body: body.to_owned(),
824 }
825 }
826
827 #[test]
828 fn test_route_detailed_logs_enabled() {
829 let router = Router::new();
830 let entry = metadata_entry("DETAILED LOGS: ENABLED");
831
832 let results = router.route(&entry);
833 assert_eq!(results.len(), 1);
834 assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
835 if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
836 assert_eq!(e.enabled(), Some(true));
837 }
838 assert_eq!(router.stats().routed_count(), 1);
839 }
840
841 #[test]
842 fn test_route_detailed_logs_disabled() {
843 let router = Router::new();
844 let entry = metadata_entry("DETAILED LOGS: DISABLED");
845
846 let results = router.route(&entry);
847 assert_eq!(results.len(), 1);
848 assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
849 if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
850 assert_eq!(e.enabled(), Some(false));
851 }
852 }
853
854 #[test]
855 fn test_route_metadata_no_timestamp_failure() {
856 let router = Router::new();
857 let entry = metadata_entry("DETAILED LOGS: ENABLED");
858
859 router.route(&entry);
860 assert_eq!(router.stats().timestamp_failure_count(), 1);
863 assert_eq!(router.stats().routed_count(), 1);
865 }
866
867 #[test]
868 fn test_route_unrecognized_metadata_returns_empty() {
869 let router = Router::new();
870 let entry = metadata_entry("SOME OTHER METADATA");
871
872 let results = router.route(&entry);
873 assert!(results.is_empty());
874 assert_eq!(router.stats().unknown_count(), 1);
875 }
876 }
877
878 mod truncation_marker_entries {
881 use super::*;
882
883 fn truncation_entry(body: &str) -> LogEntry {
884 LogEntry {
885 header: EntryHeader::TruncationMarker,
886 body: body.to_owned(),
887 }
888 }
889
890 fn marker_body(object_count: u32, annotation_count: u32) -> String {
891 format!(
892 "[Message summarized because one or more GameStateMessages \
893 exceeded the 50 GameObject or 50 Annotation limit.]\n\
894 ::: GameStateMessage\n\
895 :: GameObject Count = {object_count}\n\
896 :: Annotation Count = {annotation_count}\n\
897 ::: ActionsAvailableReq"
898 )
899 }
900
901 #[test]
902 fn test_route_truncation_marker_emits_truncation_event() {
903 let router = Router::new();
904 let entry = truncation_entry(&marker_body(63, 4));
905
906 let results = router.route(&entry);
907 assert_eq!(results.len(), 1);
908 assert!(matches!(&results[0], GameEvent::Truncation(_)));
909 assert_eq!(router.stats().routed_count(), 1);
910 assert_eq!(router.stats().unknown_count(), 0);
911 }
912
913 #[test]
914 fn test_route_truncation_marker_preserves_counts() {
915 let router = Router::new();
916 let entry = truncation_entry(&marker_body(63, 4));
917
918 let results = router.route(&entry);
919 assert_eq!(results.len(), 1);
920 let GameEvent::Truncation(ref event) = results[0] else {
921 unreachable!("expected Truncation event");
922 };
923 assert_eq!(event.object_count(), Some(63));
924 assert_eq!(event.annotation_count(), Some(4));
925 }
926
927 #[test]
928 fn test_route_truncation_marker_without_counts_is_unrecognized() {
929 let router = Router::new();
932 let body = "[Message summarized because one or more GameStateMessages \
933 exceeded the 50 GameObject or 50 Annotation limit.]";
934 let entry = truncation_entry(body);
935
936 let results = router.route(&entry);
937 assert!(results.is_empty());
938 assert_eq!(router.stats().unknown_count(), 1);
939 }
940 }
941}