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> {
258 if let Some(event) = parsers::metadata::try_parse(entry, timestamp) {
260 return vec![event];
261 }
262
263 if let Some(event) = parsers::truncation::try_parse(entry, timestamp) {
268 return vec![event];
269 }
270
271 let gre_events = parsers::gre::try_parse(entry, timestamp);
273 if !gre_events.is_empty() {
274 return gre_events;
275 }
276
277 let event = None
279 .or_else(|| parsers::client_actions::try_parse(entry, timestamp))
280 .or_else(|| parsers::match_state::try_parse(entry, timestamp))
281 .or_else(|| parsers::session::try_parse(entry, timestamp))
282 .or_else(|| parsers::draft::bot::try_parse(entry, timestamp))
283 .or_else(|| parsers::draft::human::try_parse(entry, timestamp))
284 .or_else(|| parsers::draft::complete::try_parse(entry, timestamp))
285 .or_else(|| parsers::event_lifecycle::try_parse(entry, timestamp))
286 .or_else(|| parsers::rank::try_parse(entry, timestamp))
287 .or_else(|| parsers::deck_collection::try_parse(entry, timestamp))
288 .or_else(|| parsers::inventory::try_parse(entry, timestamp))
289 .or_else(|| parsers::deck_submission::try_parse(entry, timestamp))
290 .or_else(|| parsers::connection_state::try_parse(entry, timestamp))
291 .or_else(|| parsers::connection_close::try_parse(entry, timestamp))
292 .or_else(|| parsers::connection_error::try_parse(entry, timestamp));
293
294 event.into_iter().collect()
295}
296
297#[cfg(test)]
302#[allow(deprecated)]
303mod tests {
304 use super::*;
305 use crate::log::entry::EntryHeader;
306 use chrono::Timelike;
307
308 fn unity_entry(body: &str) -> LogEntry {
310 LogEntry {
311 header: EntryHeader::UnityCrossThreadLogger,
312 body: body.to_owned(),
313 }
314 }
315
316 mod extract_timestamp_tests {
319 use super::*;
320
321 #[test]
322 fn test_extract_timestamp_us_format_with_pm() {
323 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM greToClientEvent";
324 let ts = extract_timestamp(body);
325 assert!(ts.is_some());
326 if let Some(ts) = ts {
327 assert_eq!(
328 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
329 "2026-02-25 12:00:00"
330 );
331 }
332 }
333
334 #[test]
335 fn test_extract_timestamp_us_format_with_am() {
336 let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM";
337 let ts = extract_timestamp(body);
338 assert!(ts.is_some());
339 if let Some(ts) = ts {
340 assert_eq!(
341 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
342 "2026-02-22 11:59:51"
343 );
344 }
345 }
346
347 #[test]
348 fn test_extract_timestamp_with_trailing_colon() {
349 let body = "[UnityCrossThreadLogger]3/13/2026 11:34:51 PM: Match to AAF4FC69CE47D53A";
350 let ts = extract_timestamp(body);
351 assert!(ts.is_some());
352 if let Some(ts) = ts {
353 assert_eq!(ts.hour(), 23); }
355 }
356
357 #[test]
358 fn test_extract_timestamp_24h_format() {
359 let body = "[UnityCrossThreadLogger]2026-02-25 14:30:00 some content";
360 let ts = extract_timestamp(body);
361 assert!(ts.is_some());
362 if let Some(ts) = ts {
363 assert_eq!(
364 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
365 "2026-02-25 14:30:00"
366 );
367 }
368 }
369
370 #[test]
371 fn test_extract_timestamp_no_bracket_returns_none() {
372 let body = "no bracket here";
373 let ts = extract_timestamp(body);
374 assert!(ts.is_none());
375 }
376
377 #[test]
378 fn test_extract_timestamp_empty_after_bracket_returns_none() {
379 let body = "[UnityCrossThreadLogger]";
380 let ts = extract_timestamp(body);
381 assert!(ts.is_none());
382 }
383
384 #[test]
385 fn test_extract_timestamp_no_timestamp_content_returns_none() {
386 let body = "[UnityCrossThreadLogger]FrontDoorConnection.Close";
387 let ts = extract_timestamp(body);
388 assert!(ts.is_none());
389 }
390
391 #[test]
392 fn test_extract_timestamp_timestamp_on_own_line() {
393 let body = "[UnityCrossThreadLogger]2/22/2026 11:59:51 AM\n<== StartHook(abc-123)";
394 let ts = extract_timestamp(body);
395 assert!(ts.is_some());
396 if let Some(ts) = ts {
397 assert_eq!(
398 ts.format("%Y-%m-%d %H:%M:%S").to_string(),
399 "2026-02-22 11:59:51"
400 );
401 }
402 }
403
404 #[test]
405 fn test_extract_timestamp_with_leading_space() {
406 let body = "[UnityCrossThreadLogger] 2/25/2026 12:00:00 PM event";
407 let ts = extract_timestamp(body);
408 assert!(ts.is_some());
409 }
410 }
411
412 mod known_routing {
415 use super::*;
416
417 #[test]
418 fn test_route_gre_game_state_message() {
419 let router = Router::new();
420 let payload = serde_json::json!({
421 "greToClientEvent": {
422 "greToClientMessages": [{
423 "type": "GREMessageType_GameStateMessage",
424 "gameStateMessage": {
425 "gameInfo": { "stage": "GameStage_Play" },
426 "gameObjects": [],
427 "zones": []
428 }
429 }]
430 }
431 });
432 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
433 let entry = unity_entry(&body);
434
435 let results = router.route(&entry);
436 assert_eq!(results.len(), 1);
437 assert!(matches!(&results[0], GameEvent::GameState(_)));
438 assert_eq!(router.stats().routed_count(), 1);
439 assert_eq!(router.stats().unknown_count(), 0);
440 }
441
442 #[test]
443 fn test_route_client_action() {
444 let router = Router::new();
445 let payload = serde_json::json!({
446 "clientToMatchServiceMessageType":
447 "ClientToMatchServiceMessageType_ClientToGREMessage",
448 "payload": {
449 "type": "ClientMessageType_MulliganResp",
450 "gameStateId": 5,
451 "respId": 1,
452 "mulliganResp": { "decision": "MulliganOption_Mulligan" }
453 },
454 "requestId": 12345,
455 "timestamp": "637123456789"
456 });
457 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
458 let entry = unity_entry(&body);
459
460 let results = router.route(&entry);
461 assert_eq!(results.len(), 1);
462 assert!(matches!(&results[0], GameEvent::ClientAction(_)));
463 }
464
465 #[test]
466 fn test_route_match_state() {
467 let router = Router::new();
468 let payload = serde_json::json!({
469 "matchGameRoomStateChangedEvent": {
470 "gameRoomInfo": {
471 "stateType": "MatchGameRoomStateType_Playing",
472 "gameRoomConfig": {
473 "matchId": "match-123",
474 "reservedPlayers": []
475 }
476 }
477 }
478 });
479 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
480 let entry = unity_entry(&body);
481
482 let results = router.route(&entry);
483 assert_eq!(results.len(), 1);
484 assert!(matches!(&results[0], GameEvent::MatchState(_)));
485 }
486
487 #[test]
488 fn test_route_session_authenticate_response() {
489 let router = Router::new();
490 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
491 {\"screenName\":\"TestPlayer\"}";
492 let entry = unity_entry(body);
493
494 let results = router.route(&entry);
495 assert_eq!(results.len(), 1);
496 assert!(matches!(&results[0], GameEvent::Session(_)));
497 }
498
499 #[test]
500 fn test_route_rank_event() {
501 let router = Router::new();
502 let payload = serde_json::json!({
503 "constructedClass": "Gold",
504 "constructedLevel": 2,
505 "limitedClass": "Silver",
506 "limitedLevel": 1
507 });
508 let body = format!(
509 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
510 <== RankGetCombinedRankInfo(abc-123)\n{payload}",
511 );
512 let entry = unity_entry(&body);
513
514 let results = router.route(&entry);
515 assert_eq!(results.len(), 1);
516 assert!(matches!(&results[0], GameEvent::Rank(_)));
517 }
518
519 #[test]
520 fn test_route_event_lifecycle() {
521 let router = Router::new();
522 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM \
523 ==> EventJoin {\"id\":\"abc-123\",\
524 \"request\":\"{\\\"EventName\\\":\\\"PremierDraft_MKM\\\"}\"}";
525 let entry = unity_entry(body);
526
527 let results = router.route(&entry);
528 assert_eq!(results.len(), 1);
529 assert!(matches!(&results[0], GameEvent::EventLifecycle(_)));
530 }
531
532 #[test]
533 fn test_route_draft_complete() {
534 let router = Router::new();
535 let payload = serde_json::json!({
536 "CourseId": "draft-123",
537 "InternalEventName": "PremierDraft_MKM",
538 "CardPool": [12345, 67890]
539 });
540 let body = format!(
541 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
542 <== DraftCompleteDraft(abc-123)\n{payload}",
543 );
544 let entry = unity_entry(&body);
545
546 let results = router.route(&entry);
547 assert_eq!(results.len(), 1);
548 assert!(matches!(&results[0], GameEvent::DraftComplete(_)));
549 }
550
551 #[test]
552 fn test_route_draft_bot_pack_presentation() {
553 let router = Router::new();
554 let payload = serde_json::json!({
555 "CurrentModule": "BotDraft",
556 "Payload":"{\"DraftStatus\":\"PickNext\",\"PackNumber\":0,\"PickNumber\":0,\"DraftPack\":[\"12345\",\"67890\",\"11111\"]}"
557 });
558 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n<== BotDraftDraftStatus(uuid)\n{payload}",);
559 let entry = unity_entry(&body);
560
561 let results = router.route(&entry);
562 assert_eq!(results.len(), 1);
563 assert!(matches!(&results[0], GameEvent::DraftBot(_)));
564 }
565
566 #[test]
567 fn test_route_draft_human_notify() {
568 let router = Router::new();
569 let payload = serde_json::json!({
570 "draftId": "abc-123-def",
571 "SelfPack": 0,
572 "SelfPick": 0,
573 "PackCards": "12345,67890,11111"
574 });
575 let body = format!("[UnityCrossThreadLogger]Draft.Notify\n{payload}",);
576 let entry = unity_entry(&body);
577
578 let results = router.route(&entry);
579 assert_eq!(results.len(), 1);
580 assert!(matches!(&results[0], GameEvent::DraftHuman(_)));
581 }
582
583 #[test]
584 fn test_route_start_hook_with_additional_fields_routes_to_inventory() {
585 let router = Router::new();
586 let payload = serde_json::json!({
587 "InventoryInfo": { "Gems": 100 },
588 "DeckSummariesV2": []
589 });
590 let body = format!(
591 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
592 <== StartHook(abc-123)\n{payload}",
593 );
594 let entry = unity_entry(&body);
595
596 let results = router.route(&entry);
597 assert_eq!(results.len(), 1);
598 assert!(matches!(&results[0], GameEvent::Inventory(_)));
599 }
600
601 #[test]
602 fn test_route_inventory_event() {
603 let router = Router::new();
604 let payload = serde_json::json!({
605 "InventoryInfo": { "Gems": 100, "Gold": 5000 }
606 });
607 let body = format!(
608 "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
609 <== StartHook(abc-123)\n{payload}",
610 );
611 let entry = unity_entry(&body);
612
613 let results = router.route(&entry);
614 assert_eq!(results.len(), 1);
615 assert!(matches!(&results[0], GameEvent::Inventory(_)));
616 }
617 }
618
619 mod unknown_entries {
622 use super::*;
623
624 #[test]
625 fn test_route_unknown_entry_returns_empty() {
626 let router = Router::new();
627 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
628 some unrecognized content here";
629 let entry = unity_entry(body);
630
631 let results = router.route(&entry);
632 assert!(results.is_empty());
633 }
634
635 #[test]
636 fn test_route_unknown_entry_increments_counter() {
637 let router = Router::new();
638 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n\
639 unrecognized content";
640 let entry = unity_entry(body);
641
642 router.route(&entry);
643 assert_eq!(router.stats().unknown_count(), 1);
644 assert_eq!(router.stats().routed_count(), 0);
645 }
646
647 #[test]
648 fn test_route_multiple_unknown_entries_accumulates() {
649 let router = Router::new();
650
651 for i in 0..5 {
652 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown_{i}",);
653 let entry = unity_entry(&body);
654 router.route(&entry);
655 }
656
657 assert_eq!(router.stats().unknown_count(), 5);
658 assert_eq!(router.stats().routed_count(), 0);
659 }
660
661 #[test]
662 fn test_route_empty_body_after_header_returns_empty() {
663 let router = Router::new();
664 let body = "[UnityCrossThreadLogger]";
665 let entry = unity_entry(body);
666
667 let results = router.route(&entry);
668 assert!(results.is_empty());
670 assert_eq!(router.stats().timestamp_failure_count(), 1);
671 assert_eq!(router.stats().unknown_count(), 1);
672 }
673
674 #[test]
675 fn test_route_no_timestamp_increments_timestamp_failure() {
676 let router = Router::new();
677 let body = "[UnityCrossThreadLogger]just some text without a timestamp";
678 let entry = unity_entry(body);
679
680 let results = router.route(&entry);
681 assert!(results.is_empty());
683 assert_eq!(router.stats().timestamp_failure_count(), 1);
684 assert_eq!(router.stats().unknown_count(), 1);
685 }
686
687 #[test]
688 fn test_route_no_timestamp_session_still_routes() {
689 let router = Router::new();
690 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
692 {\"screenName\":\"Player\"}";
693 let entry = unity_entry(body);
694
695 let results = router.route(&entry);
696 assert_eq!(results.len(), 1);
697 assert!(matches!(&results[0], GameEvent::Session(_)));
699 assert_eq!(router.stats().timestamp_failure_count(), 1);
700 assert_eq!(router.stats().routed_count(), 1);
701 }
702
703 #[test]
704 fn test_route_no_timestamp_passes_none_to_metadata() {
705 let router = Router::new();
706 let body = "[UnityCrossThreadLogger]authenticateResponse\n\
709 {\"screenName\":\"Player\"}";
710 let entry = unity_entry(body);
711
712 let results = router.route(&entry);
713 assert_eq!(results.len(), 1);
714 assert!(
715 results[0].metadata().timestamp().is_none(),
716 "entries without parseable timestamps should have None timestamp"
717 );
718 }
719
720 #[test]
721 fn test_route_with_timestamp_passes_some_to_metadata() {
722 let router = Router::new();
723 let payload = serde_json::json!({
724 "greToClientEvent": {
725 "greToClientMessages": [{
726 "type": "GREMessageType_GameStateMessage",
727 "gameStateMessage": {
728 "gameInfo": { "stage": "GameStage_Play" },
729 "gameObjects": [],
730 "zones": []
731 }
732 }]
733 }
734 });
735 let body = format!("[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\n{payload}");
736 let entry = unity_entry(&body);
737
738 let results = router.route(&entry);
739 assert_eq!(results.len(), 1);
740 assert!(
741 results[0].metadata().timestamp().is_some(),
742 "entries with parseable timestamps should have Some timestamp"
743 );
744 }
745 }
746
747 mod stats {
750 use super::*;
751
752 #[test]
753 fn test_stats_initial_values_are_zero() {
754 let router = Router::new();
755 assert_eq!(router.stats().routed_count(), 0);
756 assert_eq!(router.stats().unknown_count(), 0);
757 assert_eq!(router.stats().timestamp_failure_count(), 0);
758 }
759
760 #[test]
761 fn test_stats_reset_clears_all_counters() {
762 let router = Router::new();
763
764 let body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
766 let entry = unity_entry(body);
767 router.route(&entry);
768 router.route(&entry);
769
770 assert_eq!(router.stats().unknown_count(), 2);
771
772 router.stats().reset();
773
774 assert_eq!(router.stats().routed_count(), 0);
775 assert_eq!(router.stats().unknown_count(), 0);
776 assert_eq!(router.stats().timestamp_failure_count(), 0);
777 }
778
779 #[test]
780 fn test_stats_mixed_routing() {
781 let router = Router::new();
782
783 let known_body = "[UnityCrossThreadLogger]authenticateResponse\n\
785 {\"screenName\":\"Player\"}";
786 router.route(&unity_entry(known_body));
787
788 let unknown_body = "[UnityCrossThreadLogger]2/25/2026 12:00:00 PM\nunknown";
790 router.route(&unity_entry(unknown_body));
791
792 let bad_ts_body = "[UnityCrossThreadLogger]";
794 router.route(&unity_entry(bad_ts_body));
795
796 assert_eq!(router.stats().routed_count(), 1);
797 assert_eq!(router.stats().unknown_count(), 2);
799 assert_eq!(router.stats().timestamp_failure_count(), 2);
801 }
802 }
803
804 mod default_impl {
807 use super::*;
808
809 #[test]
810 fn test_router_default_creates_functional_router() {
811 let router = Router::default();
812 assert_eq!(router.stats().routed_count(), 0);
813 assert_eq!(router.stats().unknown_count(), 0);
814 }
815 }
816
817 mod metadata_entries {
820 use super::*;
821
822 fn metadata_entry(body: &str) -> LogEntry {
824 LogEntry {
825 header: EntryHeader::Metadata,
826 body: body.to_owned(),
827 }
828 }
829
830 #[test]
831 fn test_route_detailed_logs_enabled() {
832 let router = Router::new();
833 let entry = metadata_entry("DETAILED LOGS: ENABLED");
834
835 let results = router.route(&entry);
836 assert_eq!(results.len(), 1);
837 assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
838 if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
839 assert_eq!(e.enabled(), Some(true));
840 }
841 assert_eq!(router.stats().routed_count(), 1);
842 }
843
844 #[test]
845 fn test_route_detailed_logs_disabled() {
846 let router = Router::new();
847 let entry = metadata_entry("DETAILED LOGS: DISABLED");
848
849 let results = router.route(&entry);
850 assert_eq!(results.len(), 1);
851 assert!(matches!(&results[0], GameEvent::DetailedLoggingStatus(_)));
852 if let GameEvent::DetailedLoggingStatus(ref e) = results[0] {
853 assert_eq!(e.enabled(), Some(false));
854 }
855 }
856
857 #[test]
858 fn test_route_metadata_no_timestamp_failure() {
859 let router = Router::new();
860 let entry = metadata_entry("DETAILED LOGS: ENABLED");
861
862 router.route(&entry);
863 assert_eq!(router.stats().timestamp_failure_count(), 1);
866 assert_eq!(router.stats().routed_count(), 1);
868 }
869
870 #[test]
871 fn test_route_unrecognized_metadata_returns_empty() {
872 let router = Router::new();
873 let entry = metadata_entry("SOME OTHER METADATA");
874
875 let results = router.route(&entry);
876 assert!(results.is_empty());
877 assert_eq!(router.stats().unknown_count(), 1);
878 }
879 }
880
881 mod truncation_marker_entries {
884 use super::*;
885
886 fn truncation_entry(body: &str) -> LogEntry {
887 LogEntry {
888 header: EntryHeader::TruncationMarker,
889 body: body.to_owned(),
890 }
891 }
892
893 fn marker_body(object_count: u32, annotation_count: u32) -> String {
894 format!(
895 "[Message summarized because one or more GameStateMessages \
896 exceeded the 50 GameObject or 50 Annotation limit.]\n\
897 ::: GameStateMessage\n\
898 :: GameObject Count = {object_count}\n\
899 :: Annotation Count = {annotation_count}\n\
900 ::: ActionsAvailableReq"
901 )
902 }
903
904 #[test]
905 fn test_route_truncation_marker_emits_truncation_event() {
906 let router = Router::new();
907 let entry = truncation_entry(&marker_body(63, 4));
908
909 let results = router.route(&entry);
910 assert_eq!(results.len(), 1);
911 assert!(matches!(&results[0], GameEvent::Truncation(_)));
912 assert_eq!(router.stats().routed_count(), 1);
913 assert_eq!(router.stats().unknown_count(), 0);
914 }
915
916 #[test]
917 fn test_route_truncation_marker_preserves_counts() {
918 let router = Router::new();
919 let entry = truncation_entry(&marker_body(63, 4));
920
921 let results = router.route(&entry);
922 assert_eq!(results.len(), 1);
923 let GameEvent::Truncation(ref event) = results[0] else {
924 unreachable!("expected Truncation event");
925 };
926 assert_eq!(event.object_count(), Some(63));
927 assert_eq!(event.annotation_count(), Some(4));
928 }
929
930 #[test]
931 fn test_route_truncation_marker_without_counts_is_unrecognized() {
932 let router = Router::new();
935 let body = "[Message summarized because one or more GameStateMessages \
936 exceeded the 50 GameObject or 50 Annotation limit.]";
937 let entry = truncation_entry(body);
938
939 let results = router.route(&entry);
940 assert!(results.is_empty());
941 assert_eq!(router.stats().unknown_count(), 1);
942 }
943 }
944}