refactor: split the files that had grown past reading size

Five files ran between 1.2k and 2.4k lines, and one function past the
length the quality gate allows. None of it was doing anything wrong; they
had simply accumulated until you had to scroll to hold one in your head.

The move throughout is into *child* modules rather than siblings, because
a child still sees its parent's private items. So `ClientContext` keeps
its private fields and `ServerActor` keeps its private state, while the
methods that work on them move out — no encapsulation is widened for the
sake of the split. Three `ServerActor` methods become `pub(super)`, which
is only acknowledging the calls the parent already made.

The client's operation loop and the actor's standing-message match are
split by subject: the arms move out whole, so the bodies read exactly as
they did inside. Where an arm said `continue` it now says `return` — the
match was the last thing the loop did, and none of the arms that moved
holds a loop of its own, so skipping the rest of an arm and ending its
handler are the same move.

The test modules split by topic the same way, which is most of the size:
main_tui's alone was 2.1k of the 2.4k lines.

No behaviour changes. Verified against a live server: the full e2e suite
passes, including the 98 CLI and 19 daemon-protocol cases that drive the
dispatch loop this touches.
This commit is contained in:
Michel de Graaf
2026-09-10 11:48:17 +02:00
parent 26b551d997
commit 62252588a4
28 changed files with 6041 additions and 5842 deletions
+200
View File
@@ -0,0 +1,200 @@
//! What the server tells us about people: watches, interests, and the
//! two halves of a user snapshot.
use super::*;
#[test]
fn a_fresh_request_discards_the_previous_answer() {
// Without this, a poll after a second request returns the old
// snapshot immediately and the caller cannot tell stale from fresh.
let mut context = ClientContext::new();
context.apply_user_status("alice".to_string(), 2, false);
context.apply_user_stats("alice".to_string(), 10, 20, 30);
assert!(context.user_info("alice").is_some_and(|i| i.is_complete()));
context.invalidate_user_info("alice");
assert!(
context.user_info("alice").is_none(),
"a new request must not be answerable from the old reply"
);
}
#[test]
fn each_reply_fills_only_its_own_half() {
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
let info = context.user_info("bob").expect("a snapshot");
assert!(!info.is_complete(), "stats have not arrived");
assert_eq!(
info.presence.map(|p| p.status),
Some(crate::types::UserStatus::Away)
);
assert!(info.stats.is_none(), "must not invent statistics");
context.apply_user_stats("bob".to_string(), 5, 6, 7);
let info = context.user_info("bob").expect("a snapshot");
assert!(info.is_complete());
assert_eq!(info.stats.map(|s| s.shared_files), Some(6));
assert_eq!(
info.presence.map(|p| p.privileged),
Some(true),
"the earlier half must survive the merge"
);
}
#[test]
fn a_watch_reply_fills_both_halves_of_the_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_watched_user(
"alice".to_string(),
true,
Some(2),
Some(1024),
Some(20),
Some(3),
);
let info = context.user_info("alice").expect("a snapshot");
assert!(info.is_complete(), "a watch reply carries status and stats");
assert_eq!(info.stats.map(|s| s.average_speed), Some(1024));
assert_eq!(context.watched_users(), vec!["alice".to_string()]);
}
#[test]
fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() {
// WatchUser has no privileged field, so it must not overwrite what
// GetUserStatus already told us with a fabricated `false`.
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
context.apply_watched_user(
"bob".to_string(),
true,
Some(2),
Some(1),
Some(2),
Some(3),
);
let info = context.user_info("bob").expect("a snapshot");
assert_eq!(info.presence.map(|p| p.privileged), Some(true));
}
#[test]
fn watching_an_unknown_user_drops_them_from_the_list() {
// The server will never push status for a name it does not know, so
// keeping it in the watch list would show a permanently blank row.
let mut context = ClientContext::new();
context.add_watched_user("ghost");
context.apply_watched_user(
"ghost".to_string(),
false,
None,
None,
None,
None,
);
assert!(context.watched_users().is_empty());
assert!(context.user_info("ghost").is_none());
}
#[test]
fn unwatching_forgets_the_users_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_user_status("alice".to_string(), 2, false);
context.remove_watched_user("alice");
assert!(context.watched_users().is_empty());
assert!(
context.user_info("alice").is_none(),
"a later re-watch must report a fresh answer"
);
}
#[test]
fn watched_users_are_listed_in_a_stable_order() {
let mut context = ClientContext::new();
context.add_watched_user("carol");
context.add_watched_user("alice");
context.add_watched_user("bob");
assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]);
}
// A reply queued for a searcher the server cannot place waits a minute,
// not forever: every such search would otherwise pin a map entry and a
// protected slot in the peer registry.
#[test]
fn own_and_global_recommendations_are_kept_apart() {
let mut ctx = ClientContext::new();
ctx.apply_recommendations(
false,
vec![Recommendation {
item: "jazz".into(),
rating: 2,
}],
Vec::new(),
);
ctx.apply_recommendations(
true,
vec![Recommendation {
item: "pop".into(),
rating: 9,
}],
Vec::new(),
);
assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz");
assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop");
}
#[test]
fn asking_again_about_a_users_interests_drops_the_previous_answer() {
let mut ctx = ClientContext::new();
ctx.apply_user_interests(UserInterests {
username: "alice".into(),
likes: vec!["jazz".into()],
hates: Vec::new(),
});
assert!(ctx.user_interests("alice").is_some());
ctx.invalidate_user_interests("alice");
assert!(
ctx.user_interests("alice").is_none(),
"a stale answer must not be mistaken for the next one"
);
}
#[test]
fn our_own_interests_are_kept_lowercased_and_deduplicated() {
// The server matches interests case-insensitively and forgets them when
// the session ends, so they are held here in one spelling to be sent
// again next login.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.add_own_interest(" Krautrock ", true).as_deref(),
Some("krautrock")
);
assert_eq!(
ctx.add_own_interest(" ", true),
None,
"an empty interest is not stored, and is not sent either"
);
ctx.add_own_interest("KRAUTROCK", true);
ctx.add_own_interest("Muzak", false);
let interests = ctx.own_interests();
assert_eq!(interests.likes, ["krautrock"]);
assert_eq!(interests.hates, ["muzak"]);
ctx.remove_own_interest("KrautRock", true);
assert!(ctx.own_interests().likes.is_empty());
assert_eq!(
ctx.own_interests().hates,
["muzak"],
"the other list stands"
);
}