use nox_roblox::activity::{parse_ids, parse_joining, parse_server_id, Event, Watcher}; const JOIN: &str = "2026-08-06T18:20:11.123Z,0.123,abcd,6 [FLog::Output] ! Joining game '9f1e2d3c-4b5a-6789-0abc-def012345678' place 920587237 at 128.116.0.42"; const LOAD: &str = "2026-08-06T18:20:12.000Z,0.2,abcd,6 [FLog::GameJoinLoadTime] Report game_join_loadtime: placeid:920587237, universeid:245662583, userid:1421948, join_time:2.1"; const SERVER: &str = "2026-08-06T18:20:13.000Z,0.3,abcd,6 [FLog::Network] serverId: 128.116.0.42|53640"; const LEFT: &str = "2026-08-06T18:25:00.000Z,0.9,abcd,6 [FLog::Network] Time to disconnect replication data: 0.35"; #[test] fn parses_the_join_line() { let (job, place, addr) = parse_joining(JOIN).expect("join line"); assert_eq!(job, "9f1e2d3c-4b5a-6789-0abc-def012345678"); assert_eq!(place, 920587237); assert_eq!(addr, "128.116.0.42"); } #[test] fn parses_server_and_ids() { assert_eq!(parse_server_id(SERVER).as_deref(), Some("128.116.0.42")); let (universe, user) = parse_ids(LOAD); assert_eq!(universe, Some(245662583)); assert_eq!(user, Some(1421948)); } #[test] fn ignores_lines_that_do_not_match() { assert!(parse_joining("[FLog::Output] something else entirely").is_none()); assert!(parse_joining("! Joining game 'unterminated place 1 at 2").is_none()); assert!(parse_server_id("serverId: |123").is_none()); assert!(parse_server_id("[FLog::Network] nothing here").is_none()); assert_eq!(parse_ids("no ids at all"), (None, None)); } #[test] fn tracks_a_whole_session_through_the_state_machine() { let mut w = Watcher::new(std::env::temp_dir().join("nox-no-such-dir")); let mut ev = Vec::new(); w.feed(JOIN, &mut ev); assert!(matches!(ev[0], Event::Joining(_))); assert!(!w.in_game(), "joining is not yet joined"); w.feed(LOAD, &mut ev); w.feed(SERVER, &mut ev); assert!(w.in_game(), "the server line confirms the join"); let a = w.current().expect("activity"); assert_eq!(a.place_id, 920587237); assert_eq!(a.universe_id, 245662583); assert_eq!(a.server, "128.116.0.42"); assert!(a.confirmed); // A second server line must not fire a duplicate join. let before = ev.len(); w.feed(SERVER, &mut ev); assert_eq!(ev.len(), before, "already in game, no repeat event"); w.feed(LEFT, &mut ev); assert!(!w.in_game()); assert!(w.current().is_none()); assert!(matches!(ev.last().unwrap(), Event::Left)); // Leaving twice is not an event either. let before = ev.len(); w.feed(LEFT, &mut ev); assert_eq!(ev.len(), before); } #[test] fn tails_a_growing_log_file() { let dir = std::env::temp_dir().join("nox-activity-logs"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let log = dir.join("0.2026-08-06T18-20-00Z_Player_abcdef_last.log"); std::fs::write(&log, format!("{JOIN}\n")).unwrap(); let mut w = Watcher::new(&dir); let ev = w.poll(); assert_eq!(ev.len(), 1, "the first poll reads what is already there"); assert!(matches!(ev[0], Event::Joining(_))); // Nothing new appended: no events, and no re-reading of old lines. assert!(w.poll().is_empty()); // Append the rest of the join, including a line split across two polls. let mut f = std::fs::OpenOptions::new().append(true).open(&log).unwrap(); use std::io::Write; write!(f, "{LOAD}\n{}", &SERVER[..40]).unwrap(); f.flush().unwrap(); let ev = w.poll(); assert!(ev.is_empty(), "a partial line must not be parsed yet"); write!(f, "{}\n", &SERVER[40..]).unwrap(); f.flush().unwrap(); let ev = w.poll(); assert_eq!(ev.len(), 1, "the completed line now confirms the join"); assert!(w.in_game()); assert_eq!(w.current().unwrap().universe_id, 245662583); } #[test] fn switches_to_a_newer_log_file() { let dir = std::env::temp_dir().join("nox-activity-logs2"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let old = dir.join("0.2026-08-06T10-00-00Z_Player_old_last.log"); std::fs::write(&old, format!("{JOIN}\n{SERVER}\n")).unwrap(); let mut w = Watcher::new(&dir); assert_eq!(w.poll().len(), 2); assert!(w.in_game()); // A new session writes a new file; it must be picked up and read from zero. std::thread::sleep(std::time::Duration::from_millis(1100)); let new = dir.join("0.2026-08-06T11-00-00Z_Player_new_last.log"); std::fs::write(&new, format!("{JOIN}\n")).unwrap(); let ev = w.poll(); assert!( ev.iter().any(|e| matches!(e, Event::Joining(_))), "the newer log should be picked up: {ev:?}" ); assert_eq!(w.log_file().unwrap(), new); } #[test] fn a_missing_log_directory_is_harmless() { let mut w = Watcher::new("/definitely/not/a/directory"); assert!(w.poll().is_empty()); assert!(!w.in_game()); }