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