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