use graph_explorer_core::{Aggregate, Cursor, DataProvider, NeighborResult, Node, NodeId, QueryParams};
pub enum Candidate {
Real(Node),
More(Cursor),
Aggregate(Aggregate),
Loading,
}
pub enum DescendOutcome {
Refocused(NodeId),
LoadedMore,
Subsearch(Aggregate),
NoOp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionKind { Node, More, Aggregate, Loading }
impl SelectionKind {
pub fn as_str(&self) -> &'static str {
match self {
SelectionKind::Node => "node",
SelectionKind::More => "more",
SelectionKind::Aggregate => "aggregate",
SelectionKind::Loading => "loading",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SelectionInfo {
pub kind: SelectionKind,
pub id: String,
pub label: Option<String>,
pub count: Option<u64>,
}
pub struct FocusController {
pub focus: NodeId,
pub history: Vec<NodeId>,
pub candidates: Vec<Candidate>,
pub selection: usize,
limit: usize,
}
impl FocusController {
pub fn new(focus: NodeId, provider: &dyn DataProvider, limit: usize) -> Self {
let mut c = Self { focus, history: Vec::new(), candidates: Vec::new(), selection: 0, limit };
c.fetch(provider, None, false);
c
}
fn build(&mut self, res: NeighborResult, append: bool) {
if !append {
self.candidates.clear();
self.selection = 0;
}
for n in res.nodes {
if n.id == self.focus || self.history.contains(&n.id) {
continue;
}
self.candidates.push(Candidate::Real(n));
}
for mut a in res.aggregates {
a.parent = self.focus.clone();
self.candidates.push(Candidate::Aggregate(a));
}
if let Some(cur) = res.next {
self.candidates.push(Candidate::More(cur));
}
}
fn fetch(&mut self, provider: &dyn DataProvider, cursor: Option<Cursor>, append: bool) {
let res = provider.neighbors(&self.focus, &QueryParams { limit: self.limit, cursor });
if res.pending {
if !append { self.candidates.clear(); }
self.candidates.push(Candidate::Loading);
self.selection = 0;
return;
}
self.build(res, append);
}
pub fn focus_on(&mut self, id: NodeId, provider: &dyn DataProvider) {
self.focus = id;
self.fetch(provider, None, false);
}
pub fn is_pending(&self) -> bool {
self.candidates.iter().any(|c| matches!(c, Candidate::Loading))
}
pub fn refetch(&mut self, provider: &dyn DataProvider) {
self.fetch(provider, None, false);
}
pub fn select(&mut self, delta: i32) {
if self.candidates.is_empty() {
return;
}
let len = self.candidates.len() as i32;
self.selection = (self.selection as i32 + delta).rem_euclid(len) as usize;
}
pub fn select_index(&mut self, i: usize) -> bool {
match self.candidates.get(i) {
Some(Candidate::Loading) | None => false,
Some(_) => {
self.selection = i;
true
}
}
}
pub fn candidates(&self) -> &[Candidate] {
&self.candidates
}
pub fn descend(&mut self, provider: &dyn DataProvider) -> DescendOutcome {
if self.candidates.is_empty() {
return DescendOutcome::NoOp;
}
match &self.candidates[self.selection] {
Candidate::Real(n) => {
let id = n.id.clone();
self.history.push(self.focus.clone());
self.focus_on(id.clone(), provider);
DescendOutcome::Refocused(id)
}
Candidate::More(cur) => {
let cur = cur.clone();
self.candidates.remove(self.selection);
self.fetch(provider, Some(cur), true);
self.selection = self.selection.min(self.candidates.len().saturating_sub(1));
DescendOutcome::LoadedMore
}
Candidate::Aggregate(a) => DescendOutcome::Subsearch(a.clone()),
Candidate::Loading => DescendOutcome::NoOp,
}
}
pub fn selected(&self) -> Option<SelectionInfo> {
let c = self.candidates.get(self.selection)?;
Some(match c {
Candidate::Real(n) => SelectionInfo {
kind: SelectionKind::Node,
id: n.id.clone(),
label: n.label.clone(),
count: None,
},
Candidate::More(cur) => SelectionInfo {
kind: SelectionKind::More,
id: format!("__more:{}", cur.0),
label: None,
count: None,
},
Candidate::Aggregate(a) => SelectionInfo {
kind: SelectionKind::Aggregate,
id: a.id.clone(),
label: Some(a.display()),
count: Some(a.count),
},
Candidate::Loading => SelectionInfo {
kind: SelectionKind::Loading,
id: "__loading".into(),
label: None,
count: None,
},
})
}
pub fn back(&mut self, provider: &dyn DataProvider) {
if let Some(prev) = self.history.pop() {
self.focus_on(prev, provider);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use graph_explorer_core::{Aggregate, Cursor, DataProvider, GroupBy, Graph, NeighborResult, Node, NodeId, QueryParams};
struct Fake;
impl DataProvider for Fake {
fn load(&self) -> Graph { Graph::default() }
fn neighbors(&self, focus: &NodeId, params: &QueryParams) -> NeighborResult {
match focus.as_str() {
"a" => {
let all = ["x", "y", "z"];
let off = params.cursor.as_ref().and_then(|c| c.0.parse::<usize>().ok()).unwrap_or(0);
let nodes: Vec<Node> = all.iter().skip(off).take(params.limit).map(|s| Node::new(*s)).collect();
let consumed = off + nodes.len();
let next = (consumed < all.len()).then(|| Cursor(consumed.to_string()));
NeighborResult { nodes, edges: vec![], aggregates: vec![], next, pending: false }
}
"b" => NeighborResult {
nodes: vec![],
edges: vec![],
aggregates: vec![Aggregate {
id: "grp".into(),
parent: String::new(),
group_by: GroupBy::Label,
value: "items".into(),
relationships: vec![],
count: 500,
query: QueryParams { limit: 10, cursor: Some(Cursor("grp:items:0".into())) },
}],
next: None,
pending: false,
},
_ => NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false },
}
}
}
fn ids(c: &FocusController) -> Vec<String> {
c.candidates.iter().map(|cand| match cand {
Candidate::Real(n) => n.id.clone(),
Candidate::More(_) => "<more>".into(),
Candidate::Aggregate(a) => format!("<agg:{}>", a.id),
Candidate::Loading => "<loading>".into(),
}).collect()
}
#[test]
fn initial_candidates_have_a_more_when_paged() {
let c = FocusController::new("a".into(), &Fake, 2);
assert_eq!(ids(&c), vec!["x", "y", "<more>"]);
}
#[test]
fn select_wraps() {
let mut c = FocusController::new("a".into(), &Fake, 2); assert_eq!(c.selection, 0);
c.select(-1);
assert_eq!(c.selection, 2);
c.select(1);
assert_eq!(c.selection, 0);
}
#[test]
fn descend_more_appends_next_page() {
let mut c = FocusController::new("a".into(), &Fake, 2); c.selection = 2; let out = c.descend(&Fake);
assert!(matches!(out, DescendOutcome::LoadedMore));
assert_eq!(ids(&c), vec!["x", "y", "z"]); }
#[test]
fn descend_real_refocuses_and_pushes_history() {
let mut c = FocusController::new("a".into(), &Fake, 10); c.selection = 0;
let out = c.descend(&Fake);
assert!(matches!(out, DescendOutcome::Refocused(ref id) if id == "x"));
assert_eq!(c.focus, "x");
assert_eq!(c.history, vec!["a".to_string()]);
assert!(c.candidates.is_empty());
c.back(&Fake);
assert_eq!(c.focus, "a");
assert!(c.history.is_empty());
}
#[test]
fn descend_aggregate_emits_subsearch_without_changing_candidates() {
let mut c = FocusController::new("b".into(), &Fake, 10); c.selection = 0;
let before = ids(&c);
let out = c.descend(&Fake);
assert!(matches!(out, DescendOutcome::Subsearch(ref a) if a.id == "grp"));
assert_eq!(ids(&c), before, "aggregate descend is a no-op on the candidate list (deferred)");
}
struct FlakyProvider;
impl DataProvider for FlakyProvider {
fn load(&self) -> Graph { Graph::default() }
fn neighbors(&self, _focus: &NodeId, params: &QueryParams) -> NeighborResult {
let off = params.cursor.as_ref().and_then(|c| c.0.parse::<usize>().ok()).unwrap_or(0);
if off == 0 {
NeighborResult { nodes: vec![Node::new("only")], edges: vec![], aggregates: vec![], next: Some(Cursor("1".into())), pending: false }
} else {
NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: false }
}
}
}
#[test]
fn descend_more_with_empty_next_page_keeps_selection_valid() {
let mut c = FocusController::new("f".into(), &FlakyProvider, 1); assert_eq!(c.candidates.len(), 2);
c.selection = 1; let out = c.descend(&FlakyProvider);
assert!(matches!(out, DescendOutcome::LoadedMore));
assert_eq!(c.candidates.len(), 1); assert!(c.selection < c.candidates.len(), "selection must stay in bounds");
let _ = c.descend(&FlakyProvider); }
struct Undirected;
impl DataProvider for Undirected {
fn load(&self) -> Graph { Graph::default() }
fn neighbors(&self, focus: &NodeId, _params: &QueryParams) -> NeighborResult {
let nodes = match focus.as_str() {
"a" => vec![Node::new("b")],
"b" => vec![Node::new("a"), Node::new("c")],
_ => vec![],
};
NeighborResult { nodes, edges: vec![], aggregates: vec![], next: None, pending: false }
}
}
#[test]
fn descend_excludes_focus_and_history_from_candidates() {
let mut c = FocusController::new("a".into(), &Undirected, 8); assert_eq!(ids(&c), vec!["b"]);
c.selection = 0;
let out = c.descend(&Undirected);
assert!(matches!(out, DescendOutcome::Refocused(ref id) if id == "b"));
assert_eq!(c.focus, "b");
assert_eq!(c.history, vec!["a".to_string()]);
assert_eq!(ids(&c), vec!["c"]);
}
#[test]
fn descend_on_empty_candidates_is_noop() {
let mut c = FocusController::new("nobody".into(), &Fake, 5); assert!(c.candidates.is_empty());
assert!(matches!(c.descend(&Fake), DescendOutcome::NoOp));
}
struct PendingProvider;
impl DataProvider for PendingProvider {
fn load(&self) -> Graph { Graph::default() }
fn neighbors(&self, _f: &NodeId, _p: &QueryParams) -> NeighborResult {
NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true }
}
}
#[test]
fn pending_result_yields_a_single_loading_candidate() {
let fc = FocusController::new("a".into(), &PendingProvider, 8);
assert_eq!(fc.candidates.len(), 1, "one marker, not `limit` of them");
assert!(matches!(fc.candidates[0], Candidate::Loading));
}
#[test]
fn descending_a_loading_candidate_does_not_move_focus() {
let mut fc = FocusController::new("a".into(), &PendingProvider, 8);
let before = fc.focus.clone();
let _ = fc.descend(&PendingProvider);
assert_eq!(fc.focus, before, "focus must not move into a placeholder");
}
struct FillsOnSecondCall {
calls: std::cell::Cell<u32>,
}
impl DataProvider for FillsOnSecondCall {
fn load(&self) -> Graph { Graph::default() }
fn neighbors(&self, _f: &NodeId, _p: &QueryParams) -> NeighborResult {
let n = self.calls.get();
self.calls.set(n + 1);
if n == 0 {
NeighborResult { nodes: vec![], edges: vec![], aggregates: vec![], next: None, pending: true }
} else {
NeighborResult {
nodes: vec![Node { id: "real".into(), label: None, attrs: Default::default() }],
edges: vec![], aggregates: vec![], next: None, pending: false,
}
}
}
}
#[test]
fn refetch_resolves_a_pending_neighborhood() {
let p = FillsOnSecondCall { calls: std::cell::Cell::new(0) };
let mut fc = FocusController::new("a".into(), &p, 8);
assert!(fc.is_pending(), "first fetch is a placeholder");
assert!(matches!(fc.candidates[0], Candidate::Loading));
fc.refetch(&p); assert!(!fc.is_pending(), "refetch replaces the placeholder");
assert_eq!(fc.candidates.len(), 1);
assert!(matches!(&fc.candidates[0], Candidate::Real(n) if n.id == "real"));
assert_eq!(fc.focus, "a", "refetch keeps the same focus");
}
#[test]
fn fetch_stamps_the_aggregate_parent_from_the_focus() {
let c = FocusController::new("b".into(), &Fake, 10);
let agg = c.candidates.iter().find_map(|x| match x {
Candidate::Aggregate(a) => Some(a),
_ => None,
}).expect("Fake returns one aggregate for focus b");
assert_eq!(agg.parent, "b", "stamped from the queried node, not the wire");
}
#[test]
fn selected_describes_a_real_candidate() {
let c = FocusController::new("a".into(), &Fake, 10);
let s = c.selected().expect("a has candidates");
assert_eq!(s.kind, SelectionKind::Node);
assert_eq!(s.id, "x");
assert_eq!(s.count, None);
}
#[test]
fn selected_describes_an_aggregate_with_its_count() {
let mut c = FocusController::new("b".into(), &Fake, 10);
c.selection = c.candidates.iter().position(|x| matches!(x, Candidate::Aggregate(_))).unwrap();
let s = c.selected().unwrap();
assert_eq!(s.kind, SelectionKind::Aggregate);
assert_eq!(s.count, Some(500));
assert_eq!(s.label.as_deref(), Some("500 items"), "composed by display(), not transmitted");
}
#[test]
fn selected_is_none_when_there_are_no_candidates() {
let c = FocusController::new("empty".into(), &Fake, 10);
assert!(c.selected().is_none());
}
#[test]
fn select_index_moves_to_an_in_range_candidate() {
let mut c = FocusController::new("a".into(), &Fake, 10); assert!(c.select_index(1));
let s = c.selected().expect("candidate 1 exists");
assert_eq!(s.id, "y");
}
#[test]
fn select_index_rejects_out_of_range_and_keeps_selection() {
let mut c = FocusController::new("a".into(), &Fake, 10); assert_eq!(c.selection, 0);
assert!(!c.select_index(999));
assert_eq!(c.selection, 0);
assert_eq!(c.selected().unwrap().id, "x", "selection unchanged");
}
#[test]
fn select_index_rejects_loading_placeholders() {
let mut c = FocusController::new("a".into(), &PendingProvider, 8); assert_eq!(c.selection, 0);
assert!(!c.select_index(0));
assert_eq!(c.selection, 0);
assert!(matches!(c.candidates[c.selection], Candidate::Loading), "selection unchanged");
}
#[test]
fn descend_on_an_aggregate_still_changes_nothing_and_reports_it() {
let mut c = FocusController::new("b".into(), &Fake, 10);
c.selection = c.candidates.iter().position(|x| matches!(x, Candidate::Aggregate(_))).unwrap();
let before: Vec<String> = ids(&c);
match c.descend(&Fake) {
DescendOutcome::Subsearch(a) => {
assert_eq!(a.parent, "b", "the outcome carries a usable parent");
assert!(a.query.cursor.is_some(), "and a cursor the host can fetch with");
}
_ => panic!("expected Subsearch"),
}
assert_eq!(ids(&c), before, "candidates untouched");
}
}