mod common; use common::*; // ── Named Actor Registry ──────────────────────────────────────────────────── /// Given a named actor is spawned, /// when I look it up by name, /// then I get the same address that spawn returned. #[test] fn named_actor_lookup_returns_spawn_address() { let rt = std_runtime(RuntimeConfig::default()); let addr = rt.spawn_named("greeter", PingPongActor).unwrap(); assert_eq!(rt.where_is("greeter"), Some(addr)); } /// Given a named actor exists, /// when I send a message to the looked-up address, /// then the actor receives and processes it. #[test] fn named_actor_receives_messages_via_lookup() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let addr = rt.spawn_named("ponger", PingPongActor).unwrap(); assert_eq!(rt.where_is("ponger"), Some(addr)); rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); rt.tick(); assert!(inbox.try_recv().is_some(), "named actor should process message"); } /// Given a name is already registered, /// when I try to spawn another actor with the same name, /// then I get an error and the original binding is preserved. #[test] fn duplicate_name_returns_error() { let rt = std_runtime(RuntimeConfig::default()); let first_addr = rt.spawn_named("singleton", PingPongActor).unwrap(); let result = rt.spawn_named("singleton", PingPongActor); assert!(result.is_err(), "duplicate name should fail"); assert_eq!(rt.where_is("singleton"), Some(first_addr), "original binding preserved"); } /// Given no actors are registered, /// when I look up a nonexistent name, /// then I get None. #[test] fn where_is_returns_none_for_unknown_name() { let rt = std_runtime(RuntimeConfig::default()); assert_eq!(rt.where_is("ghost"), None); } /// Given a named actor is stopped, /// when the next tick runs cleanup, /// then the name is automatically unregistered. #[test] fn name_auto_unregistered_on_actor_death() { let rt = std_runtime(RuntimeConfig::default()); let addr = rt.spawn_named("ephemeral", PingPongActor).unwrap(); rt.tick(); // on_start rt.stop_actor(addr).unwrap(); rt.tick(); // process StopSignal + cleanup assert_eq!(rt.where_is("ephemeral"), None, "name should be freed after stop"); } /// Given a named actor died and its name was freed, /// when I spawn a new actor with the same name, /// then registration succeeds with a new address. #[test] fn name_can_be_reused_after_actor_death() { let rt = std_runtime(RuntimeConfig::default()); let first = rt.spawn_named("worker", PingPongActor).unwrap(); rt.tick(); rt.stop_actor(first).unwrap(); rt.tick(); // cleanup frees the name let second = rt.spawn_named("worker", PingPongActor).unwrap(); assert_ne!(first, second, "new actor should have a different address"); assert_eq!(rt.where_is("worker"), Some(second)); } /// Given a named actor panics (and is not restartable), /// when the next tick runs cleanup, /// then the name is freed. #[test] fn name_auto_unregistered_on_panic() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let _addr = rt.spawn_named("fragile", PanicActor).unwrap(); rt.tick(); // on_start rt.send_to(_addr, PanicMsg).unwrap(); rt.tick(); // panic -> poison -> cleanup assert_eq!(rt.where_is("fragile"), None, "name freed after panic"); // Can reuse the name let _new = rt.spawn_named("fragile", PingPongActor).unwrap(); assert!(rt.where_is("fragile").is_some()); drop(inbox); } /// Given multiple named actors are registered, /// when I call registered_names(), /// then all names are returned. #[test] fn registered_names_lists_all() { let rt = std_runtime(RuntimeConfig::default()); rt.spawn_named("alpha", PingPongActor).unwrap(); rt.spawn_named("beta", PingPongActor).unwrap(); rt.spawn_named("gamma", PingPongActor).unwrap(); let mut names = rt.registered_names(); names.sort(); assert_eq!(names, vec!["alpha", "beta", "gamma"]); } /// Given a named actor exists, /// when I manually unregister the name, /// then the name is freed but the actor continues running. #[test] fn manual_unregister_frees_name_but_actor_lives() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let addr = rt.spawn_named("temp-name", PingPongActor).unwrap(); rt.tick(); // on_start let removed = rt.unregister("temp-name"); assert_eq!(removed, Some(addr)); assert_eq!(rt.where_is("temp-name"), None, "name freed"); // Actor still alive and can receive messages rt.send_to(addr, Ping { reply_to: *inbox.addr() }).unwrap(); rt.tick(); assert!(inbox.try_recv().is_some(), "actor still processes messages"); } /// An actor that looks up a peer by name using ctx.where_is(). struct NameLookupActor { target_name: &'static str, reply_to: ActorAddress, } impl ActorInterface for NameLookupActor { type Incoming = Ping; type Response = (); fn handle(&mut self, ctx: &Ctx, _msg: Ping) { if let Some(peer) = ctx.where_is(self.target_name) { ctx.send(self.reply_to, MyAddr(peer)).unwrap(); } } } /// Given a named actor exists, /// when another actor calls ctx.where_is() from inside a handler, /// then it resolves the correct address. #[test] fn ctx_where_is_resolves_inside_handler() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let target = rt.spawn_named("target", PingPongActor).unwrap(); let looker = rt.spawn(NameLookupActor { target_name: "target", reply_to: *inbox.addr(), }).unwrap(); rt.tick(); // on_start rt.send_to(looker, Ping { reply_to: ActorAddress::default() }).unwrap(); rt.tick(); // handle -> where_is -> send rt.tick(); // deliver reply let result = inbox.try_recv(); assert_eq!(result, Some(MyAddr(target)), "ctx.where_is found the named actor"); } /// An actor that spawns a named child using ctx.spawn_named(). struct NamedSpawnerActor { reply_to: ActorAddress, } impl ActorInterface for NamedSpawnerActor { type Incoming = Ping; type Response = (); fn handle(&mut self, ctx: &Ctx, _msg: Ping) { match ctx.spawn_named("child", PingPongActor) { Ok(addr) => { ctx.send(self.reply_to, MyAddr(addr)).unwrap(); } Err(_) => {} } } } /// Given an actor calls ctx.spawn_named("child", ...), /// when the child is spawned, /// then where_is("child") returns the correct address. #[test] fn ctx_spawn_named_registers_from_handler() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let spawner = rt.spawn(NamedSpawnerActor { reply_to: *inbox.addr(), }).unwrap(); rt.tick(); // on_start rt.send_to(spawner, Ping { reply_to: ActorAddress::default() }).unwrap(); rt.tick(); // handle -> spawn_named rt.tick(); // deliver reply let child_addr = inbox.try_recv().expect("should receive child address"); assert_eq!(rt.where_is("child"), Some(child_addr.0), "name registered from handler"); } // ── Actor Monitoring / Death Watch ────────────────────────────────────────── /// An actor that monitors a target and forwards Down notifications to a reply address. struct WatcherActor { watch_target: ActorAddress, reply_to: ActorAddress, mref: Option, } impl ActorInterface for WatcherActor { type Incoming = Down; type Response = (); fn on_start(&mut self, ctx: &Ctx) { self.mref = Some(ctx.monitor(self.watch_target)); } fn handle(&mut self, ctx: &Ctx, msg: Down) { // Forward the Down notification to the test inbox ctx.send(self.reply_to, msg).unwrap(); } } /// Given actor A monitors actor B, /// when B is gracefully stopped, /// then A receives a Down { reason: Normal } message. #[test] fn monitor_notifies_on_graceful_stop() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let target = rt.spawn(PingPongActor).unwrap(); let _watcher = rt.spawn(WatcherActor { watch_target: target, reply_to: *inbox.addr(), mref: None, }).unwrap(); rt.tick(); // on_start -> watcher sets up monitor rt.stop_actor(target).unwrap(); rt.tick(); // target receives StopSignal -> cleanup_dead emits Down rt.tick(); // watcher receives Down -> forwards to inbox let down = inbox.try_recv().expect("should receive Down notification"); assert_eq!(down.addr, target); assert_eq!(down.reason, StopReason::Normal); } /// Given actor A monitors actor B, /// when B panics, /// then A receives a Down { reason: Panicked } message. #[test] fn monitor_notifies_on_panic() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let target = rt.spawn(PanicActor).unwrap(); let _watcher = rt.spawn(WatcherActor { watch_target: target, reply_to: *inbox.addr(), mref: None, }).unwrap(); rt.tick(); // on_start rt.send_to(target, PanicMsg).unwrap(); rt.tick(); // target panics -> cleanup_dead emits Down rt.tick(); // watcher receives Down -> forwards to inbox let down = inbox.try_recv().expect("should receive Down on panic"); assert_eq!(down.addr, target); assert_eq!(down.reason, StopReason::Panicked); } /// Given two actors both monitor the same target, /// when the target dies, /// then both watchers receive independent Down notifications. #[test] fn multiple_watchers_all_notified() { let rt = std_runtime(RuntimeConfig::default()); let inbox1 = rt.new_inbox::().unwrap(); let inbox2 = rt.new_inbox::().unwrap(); let target = rt.spawn(PingPongActor).unwrap(); rt.spawn(WatcherActor { watch_target: target, reply_to: *inbox1.addr(), mref: None, }).unwrap(); rt.spawn(WatcherActor { watch_target: target, reply_to: *inbox2.addr(), mref: None, }).unwrap(); rt.tick(); // on_start for all rt.stop_actor(target).unwrap(); rt.tick(); // cleanup -> Down emitted to both watchers rt.tick(); // watchers forward Down to inboxes assert!(inbox1.try_recv().is_some(), "watcher 1 should receive Down"); assert!(inbox2.try_recv().is_some(), "watcher 2 should receive Down"); } /// An actor that demonitors in response to a Ping message. struct DemonitorActor { watch_target: ActorAddress, mref: Option, } impl ActorInterface for DemonitorActor { type Incoming = Ping; type Response = (); fn on_start(&mut self, ctx: &Ctx) { self.mref = Some(ctx.monitor(self.watch_target)); } fn handle(&mut self, ctx: &Ctx, _msg: Ping) { // Cancel the monitor if let Some(mref) = self.mref.take() { ctx.demonitor(mref); } } } /// Given actor A monitors actor B then demonitors, /// when B dies, /// then A does NOT receive a Down notification. #[test] fn demonitor_cancels_notification() { let rt = std_runtime(RuntimeConfig::default()); let down_inbox = rt.new_inbox::().unwrap(); let target = rt.spawn(PingPongActor).unwrap(); let watcher = rt.spawn(DemonitorActor { watch_target: target, mref: None, }).unwrap(); rt.tick(); // on_start -> monitor set up // Trigger demonitor rt.send_to(watcher, Ping { reply_to: ActorAddress::default() }).unwrap(); rt.tick(); // handle -> demonitor // Now kill the target rt.stop_actor(target).unwrap(); rt.tick(); // cleanup -- no Down should be emitted rt.tick(); // extra tick to be sure assert!(down_inbox.try_recv().is_none(), "demonitored -- should NOT receive Down"); } /// Given actor A monitors B, and A dies before B, /// when B dies, /// then no Down is delivered (dead watcher cleaned up). #[test] fn dead_watcher_does_not_receive_down() { let rt = std_runtime(RuntimeConfig::default()); let target = rt.spawn(PingPongActor).unwrap(); let watcher = rt.spawn(WatcherActor { watch_target: target, reply_to: ActorAddress::default(), // won't matter, watcher dies first mref: None, }).unwrap(); rt.tick(); // on_start -> monitor set up rt.stop_actor(watcher).unwrap(); rt.tick(); // watcher dies -> its monitors are cleaned up // Now kill the target -- the dead watcher's subscription should be gone rt.stop_actor(target).unwrap(); rt.tick(); // cleanup -- should not panic or try to deliver to dead watcher // If we get here without panic, the test passes } /// Given an external inbox monitors via the runtime, /// when the target dies, /// then the inbox receives a Down message. #[test] fn down_delivered_to_external_inbox() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let target = rt.spawn(PingPongActor).unwrap(); let _watcher = rt.spawn(WatcherActor { watch_target: target, reply_to: *inbox.addr(), mref: None, }).unwrap(); rt.tick(); // on_start rt.stop_actor(target).unwrap(); rt.tick(); // cleanup -> Down to watcher rt.tick(); // watcher forwards to inbox let down = inbox.try_recv().expect("inbox should receive forwarded Down"); assert_eq!(down.addr, target); assert_eq!(down.reason, StopReason::Normal); } /// Given actor A monitors B with two independent monitors, /// when B dies, /// then A receives two Down messages (one per monitor). #[test] fn stacked_monitors_produce_multiple_notifications() { /// An actor that creates two monitors on the same target. struct DoubleWatcherActor { target: ActorAddress, reply_to: ActorAddress, } impl ActorInterface for DoubleWatcherActor { type Incoming = Down; type Response = (); fn on_start(&mut self, ctx: &Ctx) { ctx.monitor(self.target); ctx.monitor(self.target); } fn handle(&mut self, ctx: &Ctx, msg: Down) { ctx.send(self.reply_to, msg).unwrap(); } } let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let target = rt.spawn(PingPongActor).unwrap(); rt.spawn(DoubleWatcherActor { target, reply_to: *inbox.addr(), }).unwrap(); rt.tick(); // on_start -> 2 monitors rt.stop_actor(target).unwrap(); rt.tick(); // cleanup -> 2 Down messages to watcher rt.tick(); // watcher forwards both to inbox assert!(inbox.try_recv().is_some(), "first Down"); assert!(inbox.try_recv().is_some(), "second Down"); assert!(inbox.try_recv().is_none(), "no more"); } // ── Actor Groups / Pub-Sub ────────────────────────────────────────────────── /// Given actors join a group, /// when I query group_members, /// then all joined actors are listed. #[test] fn group_members_returns_joined_actors() { let rt = std_runtime(RuntimeConfig::default()); let a = rt.spawn(PingPongActor).unwrap(); let b = rt.spawn(PingPongActor).unwrap(); rt.join_group(a, "workers"); rt.join_group(b, "workers"); let mut members = rt.group_members("workers"); members.sort_by_key(|addr| addr.0); let mut expected = vec![a, b]; expected.sort_by_key(|addr| addr.0); assert_eq!(members, expected); } /// Given no actors have joined a group, /// when I query group_members, /// then the result is empty. #[test] fn empty_group_returns_no_members() { let rt = std_runtime(RuntimeConfig::default()); assert!(rt.group_members("nonexistent").is_empty()); } /// Given actors in a group, /// when a message is published to the group, /// then all members receive the message. #[test] fn publish_broadcasts_to_all_members() { let rt = std_runtime(RuntimeConfig::default()); let inbox1 = rt.new_inbox::().unwrap(); let inbox2 = rt.new_inbox::().unwrap(); let a = rt.spawn(PingPongActor).unwrap(); let b = rt.spawn(PingPongActor).unwrap(); rt.join_group(a, "pongers"); rt.join_group(b, "pongers"); rt.tick(); // on_start // Publish a Ping with inbox1's addr as reply_to. let count = rt.publish_to("pongers", Ping { reply_to: *inbox1.addr() }); assert_eq!(count, 2, "two members, two messages sent"); rt.tick(); // actors handle Ping -> send Pong to inbox1 // Both actors send to inbox1 assert!(inbox1.try_recv().is_some(), "first Pong"); assert!(inbox1.try_recv().is_some(), "second Pong"); assert!(inbox1.try_recv().is_none(), "no more"); drop(inbox2); } /// Given an actor leaves a group, /// when a message is published, /// then the leaver does not receive it. #[test] fn leave_group_stops_receiving_publishes() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let a = rt.spawn(PingPongActor).unwrap(); let b = rt.spawn(PingPongActor).unwrap(); rt.join_group(a, "pool"); rt.join_group(b, "pool"); rt.leave_group(b, "pool"); rt.tick(); // on_start let count = rt.publish_to("pool", Ping { reply_to: *inbox.addr() }); assert_eq!(count, 1, "only one member after leave"); rt.tick(); assert!(inbox.try_recv().is_some(), "one Pong from remaining member"); assert!(inbox.try_recv().is_none(), "no second Pong"); } /// Given a group member dies, /// when a message is published, /// then the dead member is not included. #[test] fn dead_actor_auto_removed_from_group() { let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); let a = rt.spawn(PingPongActor).unwrap(); let b = rt.spawn(PingPongActor).unwrap(); rt.join_group(a, "team"); rt.join_group(b, "team"); rt.tick(); // on_start rt.stop_actor(b).unwrap(); rt.tick(); // b dies, cleaned up from group let count = rt.publish_to("team", Ping { reply_to: *inbox.addr() }); assert_eq!(count, 1, "dead actor removed from group"); rt.tick(); assert!(inbox.try_recv().is_some()); assert!(inbox.try_recv().is_none()); } /// Given an actor is in multiple groups, /// when the actor dies, /// then it is removed from all groups. #[test] fn actor_removed_from_all_groups_on_death() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(PingPongActor).unwrap(); rt.join_group(actor, "alpha"); rt.join_group(actor, "beta"); rt.join_group(actor, "gamma"); rt.tick(); rt.stop_actor(actor).unwrap(); rt.tick(); // cleanup removes from all groups assert!(rt.group_members("alpha").is_empty()); assert!(rt.group_members("beta").is_empty()); assert!(rt.group_members("gamma").is_empty()); } /// Given a group becomes empty after its last member leaves, /// then the group name disappears from the active groups list. #[test] fn empty_group_auto_deleted() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(PingPongActor).unwrap(); rt.join_group(actor, "temp"); assert!(rt.groups().contains(&"temp".to_string())); rt.leave_group(actor, "temp"); assert!(!rt.groups().contains(&"temp".to_string()), "empty group should be removed"); } /// Given actors join groups from handlers using ctx.join_group(), /// when group_members is queried, /// then the joining actors are listed. #[test] fn ctx_join_group_from_handler() { struct GroupJoinerActor; impl ActorInterface for GroupJoinerActor { type Incoming = Ping; type Response = (); fn on_start(&mut self, ctx: &Ctx) { ctx.join_group("auto-joined"); } fn handle(&mut self, _ctx: &Ctx, _msg: Ping) {} } let rt = std_runtime(RuntimeConfig::default()); let a = rt.spawn(GroupJoinerActor).unwrap(); let b = rt.spawn(GroupJoinerActor).unwrap(); rt.tick(); // on_start -> both join "auto-joined" let members = rt.group_members("auto-joined"); assert_eq!(members.len(), 2); assert!(members.contains(&a)); assert!(members.contains(&b)); } /// Given an actor uses ctx.publish() from inside a handler, /// when the published message is processed, /// then all group members receive it. #[test] fn ctx_publish_broadcasts_from_handler() { #[derive(Clone)] struct BroadcastCmd { reply_to: ActorAddress, } struct BroadcasterActor; impl ActorInterface for BroadcasterActor { type Incoming = BroadcastCmd; type Response = (); fn on_start(&mut self, ctx: &Ctx) { ctx.join_group("broadcast-test"); } fn handle(&mut self, ctx: &Ctx, msg: BroadcastCmd) { ctx.publish("broadcast-test", Ping { reply_to: msg.reply_to }); } } let rt = std_runtime(RuntimeConfig::default()); let inbox = rt.new_inbox::().unwrap(); // Spawn 3 PingPongActors and one Broadcaster, all in the same group let _p1 = rt.spawn(PingPongActor).unwrap(); let _p2 = rt.spawn(PingPongActor).unwrap(); rt.join_group(_p1, "broadcast-test"); rt.join_group(_p2, "broadcast-test"); let broadcaster = rt.spawn(BroadcasterActor).unwrap(); rt.tick(); // on_start (broadcaster joins group too) // Send BroadcastCmd to broadcaster rt.send_to(broadcaster, BroadcastCmd { reply_to: *inbox.addr() }).unwrap(); rt.tick(); // broadcaster handles -> publish Ping to all 3 members (including self) rt.tick(); // PingPong actors handle Ping -> send Pong to inbox // At least 2 Pongs from the PingPongActors let mut pong_count = 0; while inbox.try_recv().is_some() { pong_count += 1; } assert!(pong_count >= 2, "at least 2 PingPong members should reply, got {pong_count}"); } // ── Ask Pattern ───────────────────────────────────────────────────────────── /// Given a PingPong actor, /// when I ask with recv_ticking, /// then I get the Pong response. #[test] fn ask_recv_ticking_returns_response() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(PingPongActor).unwrap(); rt.tick(); // on_start let pong: Pong = rt.ask(actor, |reply_to| Ping { reply_to }) .unwrap() .recv_ticking(&rt, 10) .unwrap(); assert_eq!(pong, Pong); } /// Given a CounterActor, /// when I ask multiple times, /// then each response reflects the updated state. #[test] fn ask_multiple_times_tracks_state() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(CounterActor { count: 0 }).unwrap(); rt.tick(); // on_start let c1: Count = rt.ask(actor, |reply_to| Increment { reply_to }) .unwrap().recv_ticking(&rt, 10).unwrap(); let c2: Count = rt.ask(actor, |reply_to| Increment { reply_to }) .unwrap().recv_ticking(&rt, 10).unwrap(); let c3: Count = rt.ask(actor, |reply_to| Increment { reply_to }) .unwrap().recv_ticking(&rt, 10).unwrap(); assert_eq!(c1, Count(1)); assert_eq!(c2, Count(2)); assert_eq!(c3, Count(3)); } /// Given a dead actor, /// when I ask and tick, /// then recv_ticking returns a timeout error. #[test] fn ask_timeout_when_no_response() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(PingPongActor).unwrap(); rt.tick(); rt.stop_actor(actor).unwrap(); rt.tick(); // actor dies // Ask the dead actor -- message is undeliverable, no response let result = rt.ask::(actor, |reply_to| Ping { reply_to }); // send_to may succeed or fail if let Ok(ask) = result { let err = ask.recv_ticking(&rt, 5); assert!(err.is_err(), "should timeout with no response"); } } /// Given an ask handle, /// when I use try_recv before ticking, /// then it returns None (response hasn't arrived yet). #[test] fn ask_try_recv_returns_none_before_tick() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(PingPongActor).unwrap(); rt.tick(); // on_start let ask = rt.ask::(actor, |reply_to| Ping { reply_to }).unwrap(); assert!(ask.try_recv().is_none(), "no response before ticking"); rt.tick(); // process message assert_eq!(ask.try_recv(), Some(Pong)); } /// Given an ask, the reply_addr() returns the inbox address for manual use. #[test] fn ask_reply_addr_is_accessible() { let rt = std_runtime(RuntimeConfig::default()); let actor = rt.spawn(PingPongActor).unwrap(); rt.tick(); let ask = rt.ask::(actor, |reply_to| Ping { reply_to }).unwrap(); let addr = *ask.reply_addr(); // The address should be valid (non-zero) assert_ne!(addr, ActorAddress::default()); }