finish treetest documentation sweep

This commit is contained in:
Michael Mikovsky
2026-04-24 18:30:44 -06:00
parent 9e74f6d6f3
commit b38d9d2149
6 changed files with 113 additions and 0 deletions
+9
View File
@@ -18,6 +18,10 @@ use crate::{
use super::super::App; use super::super::App;
impl App { impl App {
/// Renders the inspector pane for the current selection.
///
/// Rationale: the inspector is the only pane whose data source changes with
/// inspector mode, so it owns the `ground truth` vs `realistic` branch.
pub(super) fn render_inspector(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) { pub(super) fn render_inspector(&self, frame: &mut Frame<'_>, area: ratatui::layout::Rect) {
let selection = self.selected(); let selection = self.selected();
let body = match self.simulation.inspector_mode { let body = match self.simulation.inspector_mode {
@@ -33,6 +37,7 @@ impl App {
); );
} }
/// Renders the inspector using full scenario truth.
fn render_ground_truth_inspector(&self, selection: &Selection) -> Text<'static> { fn render_ground_truth_inspector(&self, selection: &Selection) -> Text<'static> {
match selection { match selection {
Selection::Node(node_id) => { Selection::Node(node_id) => {
@@ -84,6 +89,7 @@ impl App {
} }
} }
/// Renders the inspector using only what the root host has learned.
fn render_realistic_inspector(&self, selection: &Selection) -> Text<'static> { fn render_realistic_inspector(&self, selection: &Selection) -> Text<'static> {
match selection { match selection {
Selection::Node(node_id) => { Selection::Node(node_id) => {
@@ -133,6 +139,9 @@ impl App {
} }
Text::from(lines) Text::from(lines)
} else { } else {
// Showing an explicit unknown state is better than silently
// falling back to ground truth, because the whole point of
// realistic mode is to expose what the root does not know.
Text::from(vec![ Text::from(vec![
Line::from(node.display_path()).bold(), Line::from(node.display_path()).bold(),
Line::from( Line::from(
+39
View File
@@ -73,6 +73,23 @@ pub struct DemoNode {
impl DemoNode { impl DemoNode {
/// Returns a display path that keeps the root easy to recognize in the UI. /// Returns a display path that keeps the root easy to recognize in the UI.
///
/// # Example
/// ```rust
/// use treetest::model::{DemoNode, NodeId};
///
/// let node = DemoNode {
/// id: NodeId(0),
/// parent: None,
/// children: Vec::new(),
/// path: vec!["alpha".to_owned()],
/// title: "Alpha".to_owned(),
/// description: String::new(),
/// leaves: Vec::new(),
/// endpoint_procedures: Vec::new(),
/// };
/// assert_eq!(node.display_path(), "/alpha");
/// ```
pub fn display_path(&self) -> String { pub fn display_path(&self) -> String {
format_path(&self.path) format_path(&self.path)
} }
@@ -89,6 +106,7 @@ pub struct DemoTree {
impl DemoTree { impl DemoTree {
/// Builds a flattened tree from a recursive specification. /// Builds a flattened tree from a recursive specification.
pub fn from_root(spec: &NodeSpec) -> Self { pub fn from_root(spec: &NodeSpec) -> Self {
// Flatten once so later UI and simulation code can use stable ids.
let mut nodes = Vec::new(); let mut nodes = Vec::new();
let mut path_index = BTreeMap::new(); let mut path_index = BTreeMap::new();
let root = Self::push_node(spec, None, &[], &mut nodes, &mut path_index); let root = Self::push_node(spec, None, &[], &mut nodes, &mut path_index);
@@ -106,6 +124,8 @@ impl DemoTree {
nodes: &mut Vec<DemoNode>, nodes: &mut Vec<DemoNode>,
path_index: &mut BTreeMap<Vec<String>, NodeId>, path_index: &mut BTreeMap<Vec<String>, NodeId>,
) -> NodeId { ) -> NodeId {
// Node ids are assigned in insertion order so parent/child relationships
// can be expressed without any separate allocation table.
let id = NodeId(nodes.len()); let id = NodeId(nodes.len());
let path = if spec.segment.is_empty() { let path = if spec.segment.is_empty() {
base_path.to_vec() base_path.to_vec()
@@ -130,6 +150,7 @@ impl DemoTree {
let child_ids = spec let child_ids = spec
.children .children
.iter() .iter()
// Recurse after inserting the parent so children can record `parent: Some(id)`.
.map(|child| Self::push_node(child, Some(id), &path, nodes, path_index)) .map(|child| Self::push_node(child, Some(id), &path, nodes, path_index))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
nodes[id.0].children = child_ids; nodes[id.0].children = child_ids;
@@ -137,6 +158,9 @@ impl DemoTree {
} }
/// Returns the node with the given id. /// Returns the node with the given id.
///
/// Rationale: indexing by `NodeId` keeps later code short and avoids passing
/// mutable references deep through the UI and simulator layers.
pub fn node(&self, id: NodeId) -> &DemoNode { pub fn node(&self, id: NodeId) -> &DemoNode {
&self.nodes[id.0] &self.nodes[id.0]
} }
@@ -175,6 +199,11 @@ pub struct ScenarioDefinition {
} }
/// Formats a path the same way throughout the UI and tests. /// Formats a path the same way throughout the UI and tests.
///
/// # Example
/// ```rust
/// assert_eq!(treetest::model::format_path(&[]), "/");
/// ```
pub fn format_path(path: &[String]) -> String { pub fn format_path(path: &[String]) -> String {
if path.is_empty() { if path.is_empty() {
"/".to_owned() "/".to_owned()
@@ -184,11 +213,21 @@ pub fn format_path(path: &[String]) -> String {
} }
/// Formats a leaf reference using the protocol document's descriptive syntax. /// Formats a leaf reference using the protocol document's descriptive syntax.
///
/// # Example
/// ```rust
/// assert_eq!(treetest::model::format_leaf_ref(&["a".into()], "echo"), "/a { leaf: echo }");
/// ```
pub fn format_leaf_ref(path: &[String], leaf_name: &str) -> String { pub fn format_leaf_ref(path: &[String], leaf_name: &str) -> String {
format!("{} {{ leaf: {} }}", format_path(path), leaf_name) format!("{} {{ leaf: {} }}", format_path(path), leaf_name)
} }
/// Formats a hook reference using the protocol document's descriptive syntax. /// Formats a hook reference using the protocol document's descriptive syntax.
///
/// # Example
/// ```rust
/// assert_eq!(treetest::model::format_hook_ref(&[], 7), "/ { hook: 7 }");
/// ```
pub fn format_hook_ref(path: &[String], hook_id: u64) -> String { pub fn format_hook_ref(path: &[String], hook_id: u64) -> String {
format!("{} {{ hook: {} }}", format_path(path), hook_id) format!("{} {{ hook: {} }}", format_path(path), hook_id)
} }
+6
View File
@@ -1,4 +1,8 @@
//! Simulator stepping helpers. //! Simulator stepping helpers.
//!
//! These helpers are intentionally tiny. They advance the mailboxes one frame at
//! a time or until idle, which keeps the step-by-step demo behavior deterministic
//! and easy to explain in the UI.
use crossbeam_channel::TryRecvError; use crossbeam_channel::TryRecvError;
use unshell::protocol::decode_frame; use unshell::protocol::decode_frame;
@@ -28,6 +32,8 @@ impl Simulation {
return Ok(true); return Ok(true);
} }
Err(TryRecvError::Disconnected) => { Err(TryRecvError::Disconnected) => {
// A disconnected mailbox means the simulated topology is no
// longer internally consistent, so surface it as a hard error.
return Err(SimError::Protocol("mailbox disconnected".to_owned())); return Err(SimError::Protocol("mailbox disconnected".to_owned()));
} }
Err(TryRecvError::Empty) => {} Err(TryRecvError::Empty) => {}
+6
View File
@@ -1,4 +1,7 @@
//! Read-only simulator queries used by tests and UI widgets. //! Read-only simulator queries used by tests and UI widgets.
//!
//! Keeping these accessors separate makes it clear which simulator APIs mutate
//! protocol state and which ones merely summarize it for assertions or display.
use crate::model::Selection; use crate::model::Selection;
@@ -9,6 +12,8 @@ use super::super::types::{RecordedEvent, Simulation};
impl Simulation { impl Simulation {
/// Returns the latest fault observed at the root, if any. /// Returns the latest fault observed at the root, if any.
pub fn latest_root_fault(&self) -> Option<&FaultMessage> { pub fn latest_root_fault(&self) -> Option<&FaultMessage> {
// Walk newest-to-oldest because the footer and tests only care about the
// most recent root-visible result.
self.recorded_events self.recorded_events
.iter() .iter()
.rev() .rev()
@@ -22,6 +27,7 @@ impl Simulation {
/// Returns the latest root data message as utf-8 for tests and status text. /// Returns the latest root data message as utf-8 for tests and status text.
pub fn latest_root_data_text(&self) -> Option<String> { pub fn latest_root_data_text(&self) -> Option<String> {
// Lossy decoding keeps the query usable even for non-text payloads.
self.recorded_events self.recorded_events
.iter() .iter()
.rev() .rev()
+20
View File
@@ -51,12 +51,15 @@ pub struct RootKnowledge {
} }
impl RootKnowledge { impl RootKnowledge {
/// Builds the initial root knowledge from static scenario truth.
pub(super) fn new(tree: &crate::model::DemoTree) -> Self { pub(super) fn new(tree: &crate::model::DemoTree) -> Self {
let mut knowledge = Self { let mut knowledge = Self {
nodes: BTreeMap::new(), nodes: BTreeMap::new(),
}; };
for node in &tree.nodes { for node in &tree.nodes {
if node.path.is_empty() || node.path.len() == 1 { if node.path.is_empty() || node.path.len() == 1 {
// Realistic mode intentionally starts with root plus direct children,
// not the full transitive tree.
let direct_child = node.path.len() == 1; let direct_child = node.path.len() == 1;
let mut learned = LearnedNode { let mut learned = LearnedNode {
path: node.path.clone(), path: node.path.clone(),
@@ -69,6 +72,8 @@ impl RootKnowledge {
}; };
if node.path.is_empty() { if node.path.is_empty() {
// The root always knows its own procedures and leaves because
// those are locally configured, not discovered remotely.
learned.endpoint_procedures = node learned.endpoint_procedures = node
.endpoint_procedures .endpoint_procedures
.iter() .iter()
@@ -101,6 +106,7 @@ impl RootKnowledge {
knowledge knowledge
} }
/// Returns an existing learned node or creates a new placeholder record.
pub(super) fn ensure_node(&mut self, demo_node: &crate::model::DemoNode) -> &mut LearnedNode { pub(super) fn ensure_node(&mut self, demo_node: &crate::model::DemoNode) -> &mut LearnedNode {
let direct_child = demo_node.path.len() == 1; let direct_child = demo_node.path.len() == 1;
self.nodes self.nodes
@@ -121,6 +127,8 @@ impl RootKnowledge {
demo_node: &crate::model::DemoNode, demo_node: &crate::model::DemoNode,
procedure: &EndpointProcedureSpec, procedure: &EndpointProcedureSpec,
) { ) {
// Procedures are keyed by full `procedure_id`, so repeated observation
// simply enriches one existing record instead of duplicating it.
let learned_node = self.ensure_node(demo_node); let learned_node = self.ensure_node(demo_node);
push_procedure( push_procedure(
&mut learned_node.endpoint_procedures, &mut learned_node.endpoint_procedures,
@@ -134,6 +142,8 @@ impl RootKnowledge {
demo_node: &crate::model::DemoNode, demo_node: &crate::model::DemoNode,
leaf_spec: &crate::model::LeafSpec, leaf_spec: &crate::model::LeafSpec,
) { ) {
// Direct user targeting is enough for the root to remember a leaf exists,
// even before remote introspection returns richer confirmation.
let learned_node = self.ensure_node(demo_node); let learned_node = self.ensure_node(demo_node);
let leaf = ensure_leaf( let leaf = ensure_leaf(
&mut learned_node.leaves, &mut learned_node.leaves,
@@ -154,6 +164,8 @@ impl RootKnowledge {
demo_node: &crate::model::DemoNode, demo_node: &crate::model::DemoNode,
introspection: &EndpointIntrospection, introspection: &EndpointIntrospection,
) { ) {
// Endpoint introspection is the moment a node becomes explicitly known to
// have been queried rather than merely inferred by path.
let learned_node = self.ensure_node(demo_node); let learned_node = self.ensure_node(demo_node);
learned_node.endpoint_introspected = true; learned_node.endpoint_introspected = true;
for summary in &introspection.leaves { for summary in &introspection.leaves {
@@ -195,18 +207,23 @@ impl RootKnowledge {
} }
pub(super) fn clear_deeper_than_one_hop(&mut self) { pub(super) fn clear_deeper_than_one_hop(&mut self) {
// This powers the realistic-mode reset, which forgets transitive state
// and keeps only root-local plus direct-child knowledge.
self.nodes.retain(|path, _| path.len() <= 1); self.nodes.retain(|path, _| path.len() <= 1);
} }
/// Returns one learned node by absolute path.
pub fn node(&self, path: &[String]) -> Option<&LearnedNode> { pub fn node(&self, path: &[String]) -> Option<&LearnedNode> {
self.nodes.get(path) self.nodes.get(path)
} }
/// Returns every path currently known to the root host.
pub fn known_paths(&self) -> Vec<Vec<String>> { pub fn known_paths(&self) -> Vec<Vec<String>> {
self.nodes.keys().cloned().collect() self.nodes.keys().cloned().collect()
} }
} }
/// Returns one learned leaf entry, creating it if necessary.
fn ensure_leaf<'a>( fn ensure_leaf<'a>(
leaves: &'a mut Vec<LearnedLeaf>, leaves: &'a mut Vec<LearnedLeaf>,
leaf_name: String, leaf_name: String,
@@ -227,6 +244,7 @@ fn ensure_leaf<'a>(
leaves.last_mut().expect("just pushed") leaves.last_mut().expect("just pushed")
} }
/// Inserts or enriches one learned procedure entry.
fn push_procedure( fn push_procedure(
procedures: &mut Vec<LearnedProcedure>, procedures: &mut Vec<LearnedProcedure>,
procedure_id: String, procedure_id: String,
@@ -236,6 +254,8 @@ fn push_procedure(
.iter_mut() .iter_mut()
.find(|procedure| procedure.procedure_id == procedure_id) .find(|procedure| procedure.procedure_id == procedure_id)
{ {
// Preserve the first available description, then upgrade missing details
// later if richer information is learned from introspection or config.
if existing.description.is_none() { if existing.description.is_none() {
existing.description = description; existing.description = description;
} }
+33
View File
@@ -18,43 +18,58 @@ use super::knowledge::{InspectorMode, RootKnowledge};
/// User-facing outcome of a root-originated action. /// User-facing outcome of a root-originated action.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionResult { pub struct ActionResult {
/// Human-readable summary shown in the footer after one action completes.
pub label: String, pub label: String,
/// Hook id allocated for the action, if the action opened or used one.
pub hook_id: Option<u64>, pub hook_id: Option<u64>,
} }
/// Snapshot of a hook interaction observed by the demo. /// Snapshot of a hook interaction observed by the demo.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookSnapshot { pub struct HookSnapshot {
/// Hook identifier scoped to the root host.
pub hook_id: u64, pub hook_id: u64,
/// Host path for the hook, usually the root in this demo.
pub host_path: Vec<String>, pub host_path: Vec<String>,
/// Peer endpoint currently associated with the hook.
pub peer_path: Vec<String>, pub peer_path: Vec<String>,
/// Procedure contract that established the hook.
pub procedure_id: String, pub procedure_id: String,
/// Optional target leaf when the originating call addressed one leaf.
pub target_leaf: Option<String>, pub target_leaf: Option<String>,
/// Whether the hook has finished normally or faulted.
pub closed: bool, pub closed: bool,
/// Most recent human-readable payload summary for the UI.
pub last_message: String, pub last_message: String,
} }
/// Trace entry shown in the UI and asserted in tests. /// Trace entry shown in the UI and asserted in tests.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceEvent { pub struct TraceEvent {
/// Monotonic event number assigned by the simulator.
pub tick: u64, pub tick: u64,
/// Display path of the node that emitted the trace line.
pub node_path: String, pub node_path: String,
/// Human-readable event summary.
pub summary: String, pub summary: String,
} }
/// Summary of one local protocol event. /// Summary of one local protocol event.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecordedEvent { pub enum RecordedEvent {
/// Local hook data event.
Data { Data {
node_path: String, node_path: String,
header: PacketHeader, header: PacketHeader,
message: DataMessage, message: DataMessage,
}, },
/// Local protocol fault event.
Fault { Fault {
node_path: String, node_path: String,
header: PacketHeader, header: PacketHeader,
message: FaultMessage, message: FaultMessage,
}, },
/// Local call-delivery event.
Call { Call {
node_path: String, node_path: String,
header: PacketHeader, header: PacketHeader,
@@ -86,15 +101,22 @@ pub enum SimError {
/// Fully built simulation for one scenario. /// Fully built simulation for one scenario.
#[derive(Debug)] #[derive(Debug)]
pub struct Simulation { pub struct Simulation {
/// Active scenario definition the simulation was built from.
pub scenario: ScenarioDefinition, pub scenario: ScenarioDefinition,
/// Flattened tree model used by both simulator and UI.
pub tree: DemoTree, pub tree: DemoTree,
pub(super) nodes: Vec<SimNode>, pub(super) nodes: Vec<SimNode>,
pub(super) root_id: NodeId, pub(super) root_id: NodeId,
pub(super) next_tick: u64, pub(super) next_tick: u64,
/// Rolling trace buffer shown in the UI.
pub trace: VecDeque<TraceEvent>, pub trace: VecDeque<TraceEvent>,
/// Exact local events emitted by the protocol runtime.
pub recorded_events: Vec<RecordedEvent>, pub recorded_events: Vec<RecordedEvent>,
/// Live and historical hook snapshots for display.
pub hooks: BTreeMap<u64, HookSnapshot>, pub hooks: BTreeMap<u64, HookSnapshot>,
/// Which knowledge view the inspector currently renders.
pub inspector_mode: InspectorMode, pub inspector_mode: InspectorMode,
/// Root-host knowledge accumulated from direct config and observed traffic.
pub root_knowledge: RootKnowledge, pub root_knowledge: RootKnowledge,
pub(super) chat_sessions: BTreeMap<u64, ChatSession>, pub(super) chat_sessions: BTreeMap<u64, ChatSession>,
} }
@@ -102,25 +124,36 @@ pub struct Simulation {
/// Per-node runtime wiring used by the simulator. /// Per-node runtime wiring used by the simulator.
#[derive(Debug)] #[derive(Debug)]
pub(super) struct SimNode { pub(super) struct SimNode {
/// Optional parent node in the explicit tree.
pub(super) parent: Option<NodeId>, pub(super) parent: Option<NodeId>,
/// Child node ids in display order.
pub(super) children: Vec<NodeId>, pub(super) children: Vec<NodeId>,
/// Backing protocol runtime for this endpoint.
pub(super) endpoint: ProtocolEndpoint, pub(super) endpoint: ProtocolEndpoint,
/// Mailbox sender used by other nodes when forwarding frames here.
pub(super) tx: Sender<Envelope>, pub(super) tx: Sender<Envelope>,
/// Mailbox receiver consumed by `Simulation::step`.
pub(super) rx: Receiver<Envelope>, pub(super) rx: Receiver<Envelope>,
} }
/// Internal packet delivery envelope. /// Internal packet delivery envelope.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(super) struct Envelope { pub(super) struct Envelope {
/// Ingress side seen by the receiving protocol runtime.
pub(super) ingress: Ingress, pub(super) ingress: Ingress,
/// Fully framed packet bytes.
pub(super) frame: FrameBytes, pub(super) frame: FrameBytes,
} }
/// Application-level chat state layered on top of hook traffic. /// Application-level chat state layered on top of hook traffic.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ChatSession { pub(super) struct ChatSession {
/// Node hosting the application-level chat behavior.
pub(super) node_id: NodeId, pub(super) node_id: NodeId,
/// Hook id that the chat session is bound to.
pub(super) hook_id: u64, pub(super) hook_id: u64,
/// Path of the hook host to which replies must be routed.
pub(super) host_path: Vec<String>, pub(super) host_path: Vec<String>,
/// Procedure contract associated with the chat stream.
pub(super) procedure_id: String, pub(super) procedure_id: String,
} }