Skip to main content

apr_cli/federation/
tui.rs

1//! Federation Gateway TUI Dashboard
2//!
3//! Interactive terminal dashboard for monitoring the federation gateway.
4//! Displays: model catalog, node health, routing decisions, circuit breakers,
5//! gateway stats, and active policies.
6//!
7//! Panels match the playbook configuration in playbooks/federation-gateway.yaml
8
9use super::catalog::{DeploymentStatus, ModelCatalog};
10use super::gateway::FederationGateway;
11use super::health::{CircuitBreaker, HealthChecker};
12use super::traits::*;
13use ratatui::{
14    layout::{Constraint, Direction, Layout, Rect},
15    style::{Color, Modifier, Style},
16    text::{Line, Span},
17    widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Tabs},
18    Frame,
19};
20use std::sync::Arc;
21use std::time::Duration;
22
23// ============================================================================
24// TUI State
25// ============================================================================
26
27/// Active tab in the federation dashboard
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum FederationTab {
30    #[default]
31    Catalog,
32    Health,
33    Routing,
34    Circuits,
35    Stats,
36    Policies,
37    Help,
38}
39
40impl FederationTab {
41    pub fn titles() -> Vec<&'static str> {
42        vec![
43            "Catalog [1]",
44            "Health [2]",
45            "Routing [3]",
46            "Circuits [4]",
47            "Stats [5]",
48            "Policies [6]",
49            "Help [?]",
50        ]
51    }
52
53    pub fn index(self) -> usize {
54        match self {
55            FederationTab::Catalog => 0,
56            FederationTab::Health => 1,
57            FederationTab::Routing => 2,
58            FederationTab::Circuits => 3,
59            FederationTab::Stats => 4,
60            FederationTab::Policies => 5,
61            FederationTab::Help => 6,
62        }
63    }
64
65    pub fn from_index(index: usize) -> Self {
66        match index {
67            0 => FederationTab::Catalog,
68            1 => FederationTab::Health,
69            2 => FederationTab::Routing,
70            3 => FederationTab::Circuits,
71            4 => FederationTab::Stats,
72            5 => FederationTab::Policies,
73            _ => FederationTab::Help,
74        }
75    }
76}
77
78/// Routing decision record for display
79#[derive(Debug, Clone)]
80pub struct RoutingRecord {
81    pub request_id: String,
82    pub capability: String,
83    pub selected_node: String,
84    pub score: f64,
85    pub reason: String,
86    pub timestamp: std::time::Instant,
87}
88
89/// Circuit breaker display state
90#[derive(Debug, Clone)]
91pub struct CircuitDisplay {
92    pub node_id: String,
93    pub state: CircuitState,
94    pub failure_count: u32,
95    pub last_failure: Option<std::time::Instant>,
96    pub reset_remaining: Option<Duration>,
97}
98
99/// Active policy display
100#[derive(Debug, Clone)]
101pub struct PolicyDisplay {
102    pub name: String,
103    pub weight: f64,
104    pub enabled: bool,
105    pub description: String,
106}
107
108/// Federation dashboard application state
109pub struct FederationApp {
110    /// Current active tab
111    pub current_tab: FederationTab,
112    /// Model catalog reference
113    pub catalog: Arc<ModelCatalog>,
114    /// Health checker reference
115    pub health: Arc<HealthChecker>,
116    /// Circuit breaker reference
117    pub circuit_breaker: Arc<CircuitBreaker>,
118    /// Gateway reference (optional)
119    pub gateway: Option<Arc<FederationGateway>>,
120    /// Recent routing decisions
121    pub routing_history: Vec<RoutingRecord>,
122    /// Maximum routing history entries
123    pub max_history: usize,
124    /// Active policies
125    pub policies: Vec<PolicyDisplay>,
126    /// Should quit flag
127    pub should_quit: bool,
128    /// Status message
129    pub status_message: Option<String>,
130    /// Selected row index (for navigation)
131    pub selected_row: usize,
132}
133
134impl FederationApp {
135    /// Create new federation dashboard app
136    pub fn new(
137        catalog: Arc<ModelCatalog>,
138        health: Arc<HealthChecker>,
139        circuit_breaker: Arc<CircuitBreaker>,
140    ) -> Self {
141        let policies = vec![
142            PolicyDisplay {
143                name: "health".to_string(),
144                weight: 2.0,
145                enabled: true,
146                description: "Strongly penalize unhealthy nodes".to_string(),
147            },
148            PolicyDisplay {
149                name: "latency".to_string(),
150                weight: 1.0,
151                enabled: true,
152                description: "Prefer low-latency nodes".to_string(),
153            },
154            PolicyDisplay {
155                name: "privacy".to_string(),
156                weight: 1.0,
157                enabled: true,
158                description: "Enforce data sovereignty".to_string(),
159            },
160            PolicyDisplay {
161                name: "locality".to_string(),
162                weight: 1.0,
163                enabled: true,
164                description: "Prefer same-region nodes".to_string(),
165            },
166            PolicyDisplay {
167                name: "cost".to_string(),
168                weight: 1.0,
169                enabled: true,
170                description: "Balance cost vs performance".to_string(),
171            },
172        ];
173
174        Self {
175            current_tab: FederationTab::default(),
176            catalog,
177            health,
178            circuit_breaker,
179            gateway: None,
180            routing_history: Vec::new(),
181            max_history: 100,
182            policies,
183            should_quit: false,
184            status_message: None,
185            selected_row: 0,
186        }
187    }
188
189    /// Attach a gateway for stats
190    #[must_use]
191    pub fn with_gateway(mut self, gateway: Arc<FederationGateway>) -> Self {
192        self.gateway = Some(gateway);
193        self
194    }
195
196    /// Navigate to next tab
197    pub fn next_tab(&mut self) {
198        let next = (self.current_tab.index() + 1) % FederationTab::titles().len();
199        self.current_tab = FederationTab::from_index(next);
200        self.selected_row = 0;
201    }
202
203    /// Navigate to previous tab
204    pub fn prev_tab(&mut self) {
205        let len = FederationTab::titles().len();
206        let prev = (self.current_tab.index() + len - 1) % len;
207        self.current_tab = FederationTab::from_index(prev);
208        self.selected_row = 0;
209    }
210
211    /// Select next row
212    pub fn select_next(&mut self) {
213        self.selected_row = self.selected_row.saturating_add(1);
214    }
215
216    /// Select previous row
217    pub fn select_prev(&mut self) {
218        self.selected_row = self.selected_row.saturating_sub(1);
219    }
220
221    /// Record a routing decision
222    pub fn record_routing(&mut self, record: RoutingRecord) {
223        self.routing_history.push(record);
224        if self.routing_history.len() > self.max_history {
225            self.routing_history.remove(0);
226        }
227    }
228
229    /// Get healthy node count
230    pub fn healthy_node_count(&self) -> usize {
231        self.health.healthy_count()
232    }
233
234    /// Get total node count
235    pub fn total_node_count(&self) -> usize {
236        self.health.total_count()
237    }
238
239    /// Get success rate
240    pub fn success_rate(&self) -> f64 {
241        self.gateway.as_ref().map_or(1.0, |g| {
242            let stats = g.stats();
243            if stats.total_requests == 0 {
244                1.0
245            } else {
246                stats.successful_requests as f64 / stats.total_requests as f64
247            }
248        })
249    }
250
251    /// Get requests per second (mock for now)
252    pub fn requests_per_sec(&self) -> f64 {
253        self.gateway
254            .as_ref()
255            .map_or(0.0, |g| g.stats().total_requests as f64 / 60.0) // Approximation
256    }
257}
258
259// ============================================================================
260// Rendering
261// ============================================================================
262
263/// Render the federation dashboard
264pub fn render_federation_dashboard(f: &mut Frame<'_>, app: &FederationApp) {
265    let chunks = Layout::default()
266        .direction(Direction::Vertical)
267        .constraints([
268            Constraint::Length(3), // Title
269            Constraint::Length(3), // Tabs
270            Constraint::Min(0),    // Content
271            Constraint::Length(1), // Status bar
272        ])
273        .split(f.area());
274
275    render_title(f, chunks[0], app);
276    render_tabs(f, chunks[1], app);
277
278    match app.current_tab {
279        FederationTab::Catalog => render_catalog(f, chunks[2], app),
280        FederationTab::Health => render_health(f, chunks[2], app),
281        FederationTab::Routing => render_routing(f, chunks[2], app),
282        FederationTab::Circuits => render_circuits(f, chunks[2], app),
283        FederationTab::Stats => render_stats(f, chunks[2], app),
284        FederationTab::Policies => render_policies(f, chunks[2], app),
285        FederationTab::Help => render_help(f, chunks[2]),
286    }
287
288    render_status_bar(f, chunks[3], app);
289}
290
291fn render_title(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
292    let healthy = app.healthy_node_count();
293    let total = app.total_node_count();
294
295    let title = format!(
296        " Federation Gateway v1.0 │ {}/{} nodes healthy │ {:.1}% success ",
297        healthy,
298        total,
299        app.success_rate() * 100.0
300    );
301
302    let block = Block::default()
303        .borders(Borders::ALL)
304        .style(Style::default().fg(Color::Cyan))
305        .title(Span::styled(
306            title,
307            Style::default()
308                .fg(Color::White)
309                .add_modifier(Modifier::BOLD),
310        ));
311
312    f.render_widget(block, area);
313}
314
315fn render_tabs(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
316    let titles: Vec<Line<'_>> = FederationTab::titles()
317        .iter()
318        .map(|t| Line::from(*t))
319        .collect();
320
321    let tabs = Tabs::new(titles)
322        .block(Block::default().borders(Borders::ALL).title("Navigation"))
323        .select(app.current_tab.index())
324        .style(Style::default().fg(Color::White))
325        .highlight_style(
326            Style::default()
327                .fg(Color::Yellow)
328                .add_modifier(Modifier::BOLD),
329        );
330
331    f.render_widget(tabs, area);
332}
333
334fn render_catalog(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
335    let block = Block::default()
336        .borders(Borders::ALL)
337        .title(" MODEL CATALOG ");
338
339    let entries = app.catalog.all_entries();
340
341    if entries.is_empty() {
342        let no_models =
343            Paragraph::new("No models registered. Use 'apr federation register' to add models.")
344                .style(Style::default().fg(Color::Yellow))
345                .block(block);
346        f.render_widget(no_models, area);
347        return;
348    }
349
350    let header = Row::new(vec![
351        Cell::from("Model").style(Style::default().fg(Color::Cyan)),
352        Cell::from("Node").style(Style::default().fg(Color::Cyan)),
353        Cell::from("Region").style(Style::default().fg(Color::Cyan)),
354        Cell::from("Capabilities").style(Style::default().fg(Color::Cyan)),
355        Cell::from("Status").style(Style::default().fg(Color::Cyan)),
356    ])
357    .height(1)
358    .bottom_margin(1);
359
360    let rows: Vec<Row<'_>> = entries
361        .iter()
362        .flat_map(|entry| {
363            entry.deployments.iter().map(move |dep| {
364                let caps: String = entry
365                    .metadata
366                    .capabilities
367                    .iter()
368                    .map(|c| format!("{:?}", c))
369                    .collect::<Vec<_>>()
370                    .join(", ");
371
372                let status_style = match dep.status {
373                    DeploymentStatus::Ready => Style::default().fg(Color::Green),
374                    DeploymentStatus::Loading => Style::default().fg(Color::Yellow),
375                    DeploymentStatus::Draining => Style::default().fg(Color::Yellow),
376                    DeploymentStatus::Removed => Style::default().fg(Color::Red),
377                };
378
379                Row::new(vec![
380                    Cell::from(entry.metadata.model_id.0.clone()),
381                    Cell::from(dep.node_id.0.clone()),
382                    Cell::from(dep.region_id.0.clone()),
383                    Cell::from(caps),
384                    Cell::from(format!("{:?}", dep.status)).style(status_style),
385                ])
386            })
387        })
388        .collect();
389
390    let table = Table::new(
391        rows,
392        [
393            Constraint::Percentage(20),
394            Constraint::Percentage(20),
395            Constraint::Percentage(15),
396            Constraint::Percentage(30),
397            Constraint::Percentage(15),
398        ],
399    )
400    .header(header)
401    .block(block);
402
403    f.render_widget(table, area);
404}
405
406fn render_health(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
407    let block = Block::default()
408        .borders(Borders::ALL)
409        .title(" NODE HEALTH ");
410
411    let statuses = app.health.all_statuses();
412
413    if statuses.is_empty() {
414        let no_nodes = Paragraph::new("No nodes registered for health monitoring.")
415            .style(Style::default().fg(Color::Yellow))
416            .block(block);
417        f.render_widget(no_nodes, area);
418        return;
419    }
420
421    let header = Row::new(vec![
422        Cell::from("Node").style(Style::default().fg(Color::Cyan)),
423        Cell::from("State").style(Style::default().fg(Color::Cyan)),
424        Cell::from("Latency P50").style(Style::default().fg(Color::Cyan)),
425        Cell::from("Latency P99").style(Style::default().fg(Color::Cyan)),
426        Cell::from("Queue").style(Style::default().fg(Color::Cyan)),
427    ])
428    .height(1)
429    .bottom_margin(1);
430
431    let rows: Vec<Row<'_>> = statuses
432        .iter()
433        .map(|status| {
434            let state_style = match status.state {
435                HealthState::Healthy => Style::default().fg(Color::Green),
436                HealthState::Degraded => Style::default().fg(Color::Yellow),
437                HealthState::Unhealthy => Style::default().fg(Color::Red),
438                HealthState::Unknown => Style::default().fg(Color::DarkGray),
439            };
440
441            Row::new(vec![
442                Cell::from(status.node_id.0.clone()),
443                Cell::from(format!("{:?}", status.state)).style(state_style),
444                Cell::from(format!("{}ms", status.latency_p50.as_millis())),
445                Cell::from(format!("{}ms", status.latency_p99.as_millis())),
446                Cell::from(format!("{}", status.queue_depth)),
447            ])
448        })
449        .collect();
450
451    let table = Table::new(
452        rows,
453        [
454            Constraint::Percentage(25),
455            Constraint::Percentage(20),
456            Constraint::Percentage(20),
457            Constraint::Percentage(20),
458            Constraint::Percentage(15),
459        ],
460    )
461    .header(header)
462    .block(block);
463
464    f.render_widget(table, area);
465}
466
467fn render_routing(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
468    let block = Block::default()
469        .borders(Borders::ALL)
470        .title(" ROUTING DECISIONS ");
471
472    if app.routing_history.is_empty() {
473        let no_routing = Paragraph::new("No routing decisions recorded yet.")
474            .style(Style::default().fg(Color::Yellow))
475            .block(block);
476        f.render_widget(no_routing, area);
477        return;
478    }
479
480    let header = Row::new(vec![
481        Cell::from("Request").style(Style::default().fg(Color::Cyan)),
482        Cell::from("Capability").style(Style::default().fg(Color::Cyan)),
483        Cell::from("Selected").style(Style::default().fg(Color::Cyan)),
484        Cell::from("Score").style(Style::default().fg(Color::Cyan)),
485        Cell::from("Reason").style(Style::default().fg(Color::Cyan)),
486    ])
487    .height(1)
488    .bottom_margin(1);
489
490    let rows: Vec<Row<'_>> = app
491        .routing_history
492        .iter()
493        .rev()
494        .take(20)
495        .map(|record| {
496            Row::new(vec![
497                Cell::from(truncate(&record.request_id, 12)),
498                Cell::from(record.capability.clone()),
499                Cell::from(record.selected_node.clone()),
500                Cell::from(format!("{:.2}", record.score)),
501                Cell::from(truncate(&record.reason, 30)),
502            ])
503        })
504        .collect();
505
506    let table = Table::new(
507        rows,
508        [
509            Constraint::Percentage(15),
510            Constraint::Percentage(20),
511            Constraint::Percentage(20),
512            Constraint::Percentage(15),
513            Constraint::Percentage(30),
514        ],
515    )
516    .header(header)
517    .block(block);
518
519    f.render_widget(table, area);
520}
521
522fn render_circuits(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
523    let block = Block::default()
524        .borders(Borders::ALL)
525        .title(" CIRCUIT BREAKERS ");
526
527    let circuits = app.circuit_breaker.all_states();
528
529    if circuits.is_empty() {
530        let no_circuits = Paragraph::new("No circuit breakers active.")
531            .style(Style::default().fg(Color::DarkGray))
532            .block(block);
533        f.render_widget(no_circuits, area);
534        return;
535    }
536
537    let header = Row::new(vec![
538        Cell::from("Node").style(Style::default().fg(Color::Cyan)),
539        Cell::from("State").style(Style::default().fg(Color::Cyan)),
540        Cell::from("Failures").style(Style::default().fg(Color::Cyan)),
541        Cell::from("Last Failure").style(Style::default().fg(Color::Cyan)),
542        Cell::from("Reset In").style(Style::default().fg(Color::Cyan)),
543    ])
544    .height(1)
545    .bottom_margin(1);
546
547    let rows: Vec<Row<'_>> = circuits
548        .iter()
549        .map(|(node_id, state)| {
550            let state_style = match state {
551                CircuitState::Closed => Style::default().fg(Color::Green),
552                CircuitState::HalfOpen => Style::default().fg(Color::Yellow),
553                CircuitState::Open => Style::default().fg(Color::Red),
554            };
555
556            Row::new(vec![
557                Cell::from(node_id.0.clone()),
558                Cell::from(format!("{:?}", state)).style(state_style),
559                Cell::from("-"), // Would need failure count from circuit breaker
560                Cell::from("-"),
561                Cell::from("-"),
562            ])
563        })
564        .collect();
565
566    let table = Table::new(
567        rows,
568        [
569            Constraint::Percentage(25),
570            Constraint::Percentage(20),
571            Constraint::Percentage(15),
572            Constraint::Percentage(20),
573            Constraint::Percentage(20),
574        ],
575    )
576    .header(header)
577    .block(block);
578
579    f.render_widget(table, area);
580}
581
582fn render_stats(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
583    let block = Block::default()
584        .borders(Borders::ALL)
585        .title(" GATEWAY STATS ");
586
587    let stats = app
588        .gateway
589        .as_ref()
590        .map_or_else(GatewayStats::default, |g| g.stats());
591
592    // Split area for metrics and gauges
593    let chunks = Layout::default()
594        .direction(Direction::Vertical)
595        .constraints([
596            Constraint::Length(8), // Metrics
597            Constraint::Length(3), // Success rate gauge
598            Constraint::Min(0),    // Spacer
599        ])
600        .split(area);
601
602    // Metrics
603    let metrics = vec![
604        Line::from(vec![
605            Span::styled("Total Requests:    ", Style::default().fg(Color::Cyan)),
606            Span::raw(format!("{}", stats.total_requests)),
607        ]),
608        Line::from(vec![
609            Span::styled("Successful:        ", Style::default().fg(Color::Cyan)),
610            Span::styled(
611                format!("{}", stats.successful_requests),
612                Style::default().fg(Color::Green),
613            ),
614        ]),
615        Line::from(vec![
616            Span::styled("Failed:            ", Style::default().fg(Color::Cyan)),
617            Span::styled(
618                format!("{}", stats.failed_requests),
619                Style::default().fg(Color::Red),
620            ),
621        ]),
622        Line::from(vec![
623            Span::styled("Total Tokens:      ", Style::default().fg(Color::Cyan)),
624            Span::raw(format!("{}", stats.total_tokens)),
625        ]),
626        Line::from(vec![
627            Span::styled("Avg Latency:       ", Style::default().fg(Color::Cyan)),
628            Span::raw(format!("{}ms", stats.avg_latency.as_millis())),
629        ]),
630        Line::from(vec![
631            Span::styled("Active Streams:    ", Style::default().fg(Color::Cyan)),
632            Span::raw(format!("{}", stats.active_streams)),
633        ]),
634    ];
635
636    let metrics_widget = Paragraph::new(metrics).block(block);
637    f.render_widget(metrics_widget, chunks[0]);
638
639    // Success rate gauge
640    let success_rate = app.success_rate();
641    let gauge_color = if success_rate >= 0.99 {
642        Color::Green
643    } else if success_rate >= 0.95 {
644        Color::Yellow
645    } else {
646        Color::Red
647    };
648
649    let gauge = Gauge::default()
650        .block(Block::default().borders(Borders::ALL).title("Success Rate"))
651        .gauge_style(Style::default().fg(gauge_color))
652        .ratio(success_rate)
653        .label(format!("{:.1}%", success_rate * 100.0));
654
655    f.render_widget(gauge, chunks[1]);
656}
657
658fn render_policies(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
659    let block = Block::default()
660        .borders(Borders::ALL)
661        .title(" ACTIVE POLICIES ");
662
663    let header = Row::new(vec![
664        Cell::from("Policy").style(Style::default().fg(Color::Cyan)),
665        Cell::from("Weight").style(Style::default().fg(Color::Cyan)),
666        Cell::from("Status").style(Style::default().fg(Color::Cyan)),
667        Cell::from("Description").style(Style::default().fg(Color::Cyan)),
668    ])
669    .height(1)
670    .bottom_margin(1);
671
672    let rows: Vec<Row<'_>> = app
673        .policies
674        .iter()
675        .map(|policy| {
676            let status_style = if policy.enabled {
677                Style::default().fg(Color::Green)
678            } else {
679                Style::default().fg(Color::DarkGray)
680            };
681
682            Row::new(vec![
683                Cell::from(policy.name.clone()),
684                Cell::from(format!("{:.1}", policy.weight)),
685                Cell::from(if policy.enabled { "Active" } else { "Disabled" }).style(status_style),
686                Cell::from(policy.description.clone()),
687            ])
688        })
689        .collect();
690
691    let table = Table::new(
692        rows,
693        [
694            Constraint::Percentage(15),
695            Constraint::Percentage(10),
696            Constraint::Percentage(15),
697            Constraint::Percentage(60),
698        ],
699    )
700    .header(header)
701    .block(block);
702
703    f.render_widget(table, area);
704}
705
706fn render_help(f: &mut Frame<'_>, area: Rect) {
707    let help_text = vec![
708        Line::from(Span::styled(
709            "Federation Gateway Dashboard",
710            Style::default()
711                .fg(Color::Cyan)
712                .add_modifier(Modifier::BOLD),
713        )),
714        Line::from(""),
715        Line::from(Span::styled(
716            "Keyboard Shortcuts",
717            Style::default()
718                .fg(Color::Cyan)
719                .add_modifier(Modifier::BOLD),
720        )),
721        Line::from(""),
722        Line::from(vec![
723            Span::styled("1-6, ?    ", Style::default().fg(Color::Yellow)),
724            Span::raw("Switch to Catalog/Health/Routing/Circuits/Stats/Policies/Help"),
725        ]),
726        Line::from(vec![
727            Span::styled("Tab       ", Style::default().fg(Color::Yellow)),
728            Span::raw("Next tab"),
729        ]),
730        Line::from(vec![
731            Span::styled("Shift+Tab ", Style::default().fg(Color::Yellow)),
732            Span::raw("Previous tab"),
733        ]),
734        Line::from(vec![
735            Span::styled("j / Down  ", Style::default().fg(Color::Yellow)),
736            Span::raw("Select next row"),
737        ]),
738        Line::from(vec![
739            Span::styled("k / Up    ", Style::default().fg(Color::Yellow)),
740            Span::raw("Select previous row"),
741        ]),
742        Line::from(vec![
743            Span::styled("r         ", Style::default().fg(Color::Yellow)),
744            Span::raw("Refresh data"),
745        ]),
746        Line::from(vec![
747            Span::styled("h         ", Style::default().fg(Color::Yellow)),
748            Span::raw("Toggle health panel"),
749        ]),
750        Line::from(vec![
751            Span::styled("c         ", Style::default().fg(Color::Yellow)),
752            Span::raw("Toggle circuit panel"),
753        ]),
754        Line::from(vec![
755            Span::styled("s         ", Style::default().fg(Color::Yellow)),
756            Span::raw("Toggle stats panel"),
757        ]),
758        Line::from(vec![
759            Span::styled("q / Esc   ", Style::default().fg(Color::Yellow)),
760            Span::raw("Quit"),
761        ]),
762        Line::from(""),
763        Line::from(Span::styled(
764            "Panels",
765            Style::default()
766                .fg(Color::Cyan)
767                .add_modifier(Modifier::BOLD),
768        )),
769        Line::from(""),
770        Line::from("• Catalog: View registered models and deployments"),
771        Line::from("• Health: Monitor node health and latency"),
772        Line::from("• Routing: Track routing decisions and scores"),
773        Line::from("• Circuits: View circuit breaker states"),
774        Line::from("• Stats: Gateway statistics and success rate"),
775        Line::from("• Policies: Active routing policies"),
776    ];
777
778    let help =
779        Paragraph::new(help_text).block(Block::default().borders(Borders::ALL).title(" Help "));
780    f.render_widget(help, area);
781}
782
783fn render_status_bar(f: &mut Frame<'_>, area: Rect, app: &FederationApp) {
784    let _status = app
785        .status_message
786        .as_deref()
787        .unwrap_or("Tab to switch views │ q to quit │ ? for help");
788
789    let left = "Federation Gateway v1.0";
790    let center = format!(
791        "{}/{} nodes healthy",
792        app.healthy_node_count(),
793        app.total_node_count()
794    );
795    let right = format!(
796        "{:.1} req/s │ {:.1}% success",
797        app.requests_per_sec(),
798        app.success_rate() * 100.0
799    );
800
801    let full_status = format!("{} │ {} │ {}", left, center, right);
802
803    let status_bar = Paragraph::new(full_status).style(Style::default().fg(Color::DarkGray));
804    f.render_widget(status_bar, area);
805}
806
807fn truncate(s: &str, max_len: usize) -> String {
808    if s.len() <= max_len {
809        s.to_string()
810    } else {
811        format!("{}...", &s[..max_len.saturating_sub(3)])
812    }
813}
814
815// ============================================================================
816// Tests
817// ============================================================================
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn test_federation_tab_titles() {
825        let titles = FederationTab::titles();
826        assert_eq!(titles.len(), 7);
827        assert!(titles[0].contains("Catalog"));
828        assert!(titles[1].contains("Health"));
829        assert!(titles[2].contains("Routing"));
830        assert!(titles[3].contains("Circuits"));
831        assert!(titles[4].contains("Stats"));
832        assert!(titles[5].contains("Policies"));
833        assert!(titles[6].contains("Help"));
834    }
835
836    #[test]
837    fn test_federation_tab_index() {
838        assert_eq!(FederationTab::Catalog.index(), 0);
839        assert_eq!(FederationTab::Health.index(), 1);
840        assert_eq!(FederationTab::Routing.index(), 2);
841        assert_eq!(FederationTab::Circuits.index(), 3);
842        assert_eq!(FederationTab::Stats.index(), 4);
843        assert_eq!(FederationTab::Policies.index(), 5);
844        assert_eq!(FederationTab::Help.index(), 6);
845    }
846
847    #[test]
848    fn test_federation_tab_from_index() {
849        assert_eq!(FederationTab::from_index(0), FederationTab::Catalog);
850        assert_eq!(FederationTab::from_index(1), FederationTab::Health);
851        assert_eq!(FederationTab::from_index(2), FederationTab::Routing);
852        assert_eq!(FederationTab::from_index(3), FederationTab::Circuits);
853        assert_eq!(FederationTab::from_index(4), FederationTab::Stats);
854        assert_eq!(FederationTab::from_index(5), FederationTab::Policies);
855        assert_eq!(FederationTab::from_index(6), FederationTab::Help);
856        assert_eq!(FederationTab::from_index(99), FederationTab::Help);
857    }
858
859    #[test]
860    fn test_federation_app_new() {
861        let catalog = Arc::new(ModelCatalog::new());
862        let health = Arc::new(HealthChecker::default());
863        let circuit_breaker = Arc::new(CircuitBreaker::default());
864
865        let app = FederationApp::new(catalog, health, circuit_breaker);
866
867        assert_eq!(app.current_tab, FederationTab::Catalog);
868        assert!(!app.should_quit);
869        assert!(app.routing_history.is_empty());
870        assert_eq!(app.policies.len(), 5);
871    }
872
873    #[test]
874    fn test_federation_app_tab_navigation() {
875        let catalog = Arc::new(ModelCatalog::new());
876        let health = Arc::new(HealthChecker::default());
877        let circuit_breaker = Arc::new(CircuitBreaker::default());
878
879        let mut app = FederationApp::new(catalog, health, circuit_breaker);
880
881        assert_eq!(app.current_tab, FederationTab::Catalog);
882        app.next_tab();
883        assert_eq!(app.current_tab, FederationTab::Health);
884        app.next_tab();
885        assert_eq!(app.current_tab, FederationTab::Routing);
886        app.prev_tab();
887        assert_eq!(app.current_tab, FederationTab::Health);
888    }
889
890    #[test]
891    fn test_federation_app_routing_history() {
892        let catalog = Arc::new(ModelCatalog::new());
893        let health = Arc::new(HealthChecker::default());
894        let circuit_breaker = Arc::new(CircuitBreaker::default());
895
896        let mut app = FederationApp::new(catalog, health, circuit_breaker);
897
898        let record = RoutingRecord {
899            request_id: "req-1".to_string(),
900            capability: "Transcribe".to_string(),
901            selected_node: "node-1".to_string(),
902            score: 0.95,
903            reason: "lowest latency".to_string(),
904            timestamp: std::time::Instant::now(),
905        };
906
907        app.record_routing(record);
908        assert_eq!(app.routing_history.len(), 1);
909    }
910
911    #[test]
912    fn test_truncate() {
913        assert_eq!(truncate("short", 10), "short");
914        assert_eq!(truncate("very long string", 10), "very lo...");
915        assert_eq!(truncate("exactly10", 10), "exactly10");
916    }
917
918    // =========================================================================
919    // FederationTab extended tests
920    // =========================================================================
921
922    #[test]
923    fn test_federation_tab_default() {
924        let tab = FederationTab::default();
925        assert_eq!(tab, FederationTab::Catalog);
926    }
927
928    #[test]
929    fn test_federation_tab_roundtrip_all() {
930        for i in 0..7 {
931            let tab = FederationTab::from_index(i);
932            assert_eq!(tab.index(), i);
933        }
934    }
935
936    #[test]
937    fn test_federation_tab_from_index_overflow() {
938        // Any index >= 6 should map to Help
939        assert_eq!(FederationTab::from_index(7), FederationTab::Help);
940        assert_eq!(FederationTab::from_index(100), FederationTab::Help);
941        assert_eq!(FederationTab::from_index(usize::MAX), FederationTab::Help);
942    }
943
944    #[test]
945    fn test_federation_tab_copy() {
946        let tab = FederationTab::Routing;
947        let copied = tab;
948        assert_eq!(tab, copied);
949    }
950
951    #[test]
952    fn test_federation_tab_debug() {
953        let tab = FederationTab::Stats;
954        let debug = format!("{:?}", tab);
955        assert_eq!(debug, "Stats");
956    }
957
958    // =========================================================================
959    // Truncate edge cases
960    // =========================================================================
961
962    #[test]
963    fn test_truncate_empty() {
964        assert_eq!(truncate("", 10), "");
965    }
966
967    #[test]
968    fn test_truncate_exactly_at_limit() {
969        assert_eq!(truncate("1234567890", 10), "1234567890");
970    }
971
972    #[test]
973    fn test_truncate_one_over() {
974        assert_eq!(truncate("12345678901", 10), "1234567...");
975    }
976
977    #[test]
978    fn test_truncate_max_len_3() {
979        // max_len=3, 3-3=0 -> "..."
980        assert_eq!(truncate("abcdef", 3), "...");
981    }
982
983    #[test]
984    fn test_truncate_max_len_4() {
985        assert_eq!(truncate("abcdef", 4), "a...");
986    }
987
988    // =========================================================================
989    // RoutingRecord construction
990    // =========================================================================
991
992    #[test]
993    fn test_routing_record_construction() {
994        let record = RoutingRecord {
995            request_id: "req-42".to_string(),
996            capability: "Generate".to_string(),
997            selected_node: "gpu-node-1".to_string(),
998            score: 0.95,
999            reason: "lowest latency".to_string(),
1000            timestamp: std::time::Instant::now(),
1001        };
1002        assert_eq!(record.request_id, "req-42");
1003        assert_eq!(record.capability, "Generate");
1004        assert_eq!(record.score, 0.95);
1005    }
1006
1007    #[test]
1008    fn test_routing_record_clone() {
1009        let record = RoutingRecord {
1010            request_id: "req-1".to_string(),
1011            capability: "Embed".to_string(),
1012            selected_node: "node-1".to_string(),
1013            score: 0.5,
1014            reason: "only available".to_string(),
1015            timestamp: std::time::Instant::now(),
1016        };
1017        let cloned = record.clone();
1018        assert_eq!(cloned.request_id, "req-1");
1019    }
1020
1021    // =========================================================================
1022    // CircuitDisplay construction
1023    // =========================================================================
1024
1025    #[test]
1026    fn test_circuit_display_construction() {
1027        let display = CircuitDisplay {
1028            node_id: "node-1".to_string(),
1029            state: CircuitState::Open,
1030            failure_count: 5,
1031            last_failure: Some(std::time::Instant::now()),
1032            reset_remaining: Some(Duration::from_secs(25)),
1033        };
1034        assert_eq!(display.node_id, "node-1");
1035        assert_eq!(display.state, CircuitState::Open);
1036        assert_eq!(display.failure_count, 5);
1037        assert!(display.last_failure.is_some());
1038        assert!(display.reset_remaining.is_some());
1039    }
1040
1041    #[test]
1042    fn test_circuit_display_closed() {
1043        let display = CircuitDisplay {
1044            node_id: "healthy-node".to_string(),
1045            state: CircuitState::Closed,
1046            failure_count: 0,
1047            last_failure: None,
1048            reset_remaining: None,
1049        };
1050        assert_eq!(display.state, CircuitState::Closed);
1051        assert!(display.last_failure.is_none());
1052    }
1053
1054    // =========================================================================
1055    // PolicyDisplay construction
1056    // =========================================================================
1057
1058    #[test]
1059    fn test_policy_display_enabled() {
1060        let display = PolicyDisplay {
1061            name: "latency".to_string(),
1062            weight: 1.0,
1063            enabled: true,
1064            description: "Prefer low-latency nodes".to_string(),
1065        };
1066        assert!(display.enabled);
1067        assert_eq!(display.weight, 1.0);
1068    }
1069
1070    #[test]
1071    fn test_policy_display_disabled() {
1072        let display = PolicyDisplay {
1073            name: "cost".to_string(),
1074            weight: 0.5,
1075            enabled: false,
1076            description: "Disabled for testing".to_string(),
1077        };
1078        assert!(!display.enabled);
1079    }
1080
1081    // =========================================================================
1082    // FederationApp extended tests
1083    // =========================================================================
1084
1085    #[test]
1086    fn test_federation_app_with_gateway() {
1087        use super::super::gateway::FederationGateway;
1088        use super::super::routing::{Router, RouterConfig};
1089
1090        let catalog = Arc::new(ModelCatalog::new());
1091        let health = Arc::new(HealthChecker::default());
1092        let circuit_breaker = Arc::new(CircuitBreaker::default());
1093
1094        let router = Arc::new(Router::new(
1095            RouterConfig::default(),
1096            Arc::clone(&catalog),
1097            Arc::clone(&health),
1098            Arc::clone(&circuit_breaker),
1099        ));
1100
1101        let gateway = Arc::new(FederationGateway::new(
1102            super::super::gateway::GatewayConfig::default(),
1103            router,
1104            Arc::clone(&health),
1105            Arc::clone(&circuit_breaker),
1106        ));
1107
1108        let app = FederationApp::new(
1109            Arc::clone(&catalog),
1110            Arc::clone(&health),
1111            Arc::clone(&circuit_breaker),
1112        )
1113        .with_gateway(gateway);
1114
1115        assert!(app.gateway.is_some());
1116    }
1117
1118    #[test]
1119    fn test_federation_app_success_rate_no_gateway() {
1120        let catalog = Arc::new(ModelCatalog::new());
1121        let health = Arc::new(HealthChecker::default());
1122        let circuit_breaker = Arc::new(CircuitBreaker::default());
1123        let app = FederationApp::new(catalog, health, circuit_breaker);
1124
1125        // Without gateway, default is 1.0
1126        assert!((app.success_rate() - 1.0).abs() < f64::EPSILON);
1127    }
1128
1129    #[test]
1130    fn test_federation_app_requests_per_sec_no_gateway() {
1131        let catalog = Arc::new(ModelCatalog::new());
1132        let health = Arc::new(HealthChecker::default());
1133        let circuit_breaker = Arc::new(CircuitBreaker::default());
1134        let app = FederationApp::new(catalog, health, circuit_breaker);
1135
1136        assert!((app.requests_per_sec() - 0.0).abs() < f64::EPSILON);
1137    }
1138
1139    #[test]
1140    fn test_federation_app_healthy_node_count() {
1141        let catalog = Arc::new(ModelCatalog::new());
1142        let health = Arc::new(HealthChecker::default());
1143        let circuit_breaker = Arc::new(CircuitBreaker::default());
1144        let app = FederationApp::new(catalog, health, circuit_breaker);
1145
1146        assert_eq!(app.healthy_node_count(), 0);
1147        assert_eq!(app.total_node_count(), 0);
1148    }
1149
1150    #[test]
1151    fn test_federation_app_select_next() {
1152        let catalog = Arc::new(ModelCatalog::new());
1153        let health = Arc::new(HealthChecker::default());
1154        let circuit_breaker = Arc::new(CircuitBreaker::default());
1155        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1156
1157        assert_eq!(app.selected_row, 0);
1158        app.select_next();
1159        assert_eq!(app.selected_row, 1);
1160        app.select_next();
1161        assert_eq!(app.selected_row, 2);
1162    }
1163
1164    #[test]
1165    fn test_federation_app_select_prev_at_zero() {
1166        let catalog = Arc::new(ModelCatalog::new());
1167        let health = Arc::new(HealthChecker::default());
1168        let circuit_breaker = Arc::new(CircuitBreaker::default());
1169        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1170
1171        assert_eq!(app.selected_row, 0);
1172        app.select_prev(); // saturating_sub(1) at 0 stays 0
1173        assert_eq!(app.selected_row, 0);
1174    }
1175
1176    #[test]
1177    fn test_federation_app_select_prev_from_nonzero() {
1178        let catalog = Arc::new(ModelCatalog::new());
1179        let health = Arc::new(HealthChecker::default());
1180        let circuit_breaker = Arc::new(CircuitBreaker::default());
1181        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1182
1183        app.selected_row = 5;
1184        app.select_prev();
1185        assert_eq!(app.selected_row, 4);
1186    }
1187
1188    #[test]
1189    fn test_federation_app_tab_navigation_resets_row() {
1190        let catalog = Arc::new(ModelCatalog::new());
1191        let health = Arc::new(HealthChecker::default());
1192        let circuit_breaker = Arc::new(CircuitBreaker::default());
1193        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1194
1195        app.selected_row = 5;
1196        app.next_tab();
1197        assert_eq!(app.selected_row, 0);
1198
1199        app.selected_row = 3;
1200        app.prev_tab();
1201        assert_eq!(app.selected_row, 0);
1202    }
1203
1204    #[test]
1205    fn test_federation_app_next_tab_wraps() {
1206        let catalog = Arc::new(ModelCatalog::new());
1207        let health = Arc::new(HealthChecker::default());
1208        let circuit_breaker = Arc::new(CircuitBreaker::default());
1209        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1210
1211        // Navigate to last tab (Help, index 6)
1212        app.current_tab = FederationTab::Help;
1213        app.next_tab();
1214        // Should wrap to Catalog (index 0)
1215        assert_eq!(app.current_tab, FederationTab::Catalog);
1216    }
1217
1218    #[test]
1219    fn test_federation_app_prev_tab_wraps() {
1220        let catalog = Arc::new(ModelCatalog::new());
1221        let health = Arc::new(HealthChecker::default());
1222        let circuit_breaker = Arc::new(CircuitBreaker::default());
1223        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1224
1225        // At Catalog (index 0), prev should wrap to Help (index 6)
1226        app.current_tab = FederationTab::Catalog;
1227        app.prev_tab();
1228        assert_eq!(app.current_tab, FederationTab::Help);
1229    }
1230
1231    #[test]
1232    fn test_federation_app_routing_history_overflow() {
1233        let catalog = Arc::new(ModelCatalog::new());
1234        let health = Arc::new(HealthChecker::default());
1235        let circuit_breaker = Arc::new(CircuitBreaker::default());
1236        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1237
1238        app.max_history = 3;
1239
1240        // Add 5 records
1241        for i in 0..5 {
1242            app.record_routing(RoutingRecord {
1243                request_id: format!("req-{}", i),
1244                capability: "Generate".to_string(),
1245                selected_node: "node-1".to_string(),
1246                score: 0.9,
1247                reason: "test".to_string(),
1248                timestamp: std::time::Instant::now(),
1249            });
1250        }
1251
1252        // Should be capped at max_history
1253        assert_eq!(app.routing_history.len(), 3);
1254        // First entries should be removed (FIFO)
1255        assert_eq!(app.routing_history[0].request_id, "req-2");
1256        assert_eq!(app.routing_history[1].request_id, "req-3");
1257        assert_eq!(app.routing_history[2].request_id, "req-4");
1258    }
1259
1260    #[test]
1261    fn test_federation_app_status_message() {
1262        let catalog = Arc::new(ModelCatalog::new());
1263        let health = Arc::new(HealthChecker::default());
1264        let circuit_breaker = Arc::new(CircuitBreaker::default());
1265        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1266
1267        assert!(app.status_message.is_none());
1268        app.status_message = Some("Connected to 3 nodes".to_string());
1269        assert_eq!(app.status_message, Some("Connected to 3 nodes".to_string()));
1270    }
1271
1272    #[test]
1273    fn test_federation_app_should_quit() {
1274        let catalog = Arc::new(ModelCatalog::new());
1275        let health = Arc::new(HealthChecker::default());
1276        let circuit_breaker = Arc::new(CircuitBreaker::default());
1277        let mut app = FederationApp::new(catalog, health, circuit_breaker);
1278
1279        assert!(!app.should_quit);
1280        app.should_quit = true;
1281        assert!(app.should_quit);
1282    }
1283
1284    #[test]
1285    fn test_federation_app_policies_count() {
1286        let catalog = Arc::new(ModelCatalog::new());
1287        let health = Arc::new(HealthChecker::default());
1288        let circuit_breaker = Arc::new(CircuitBreaker::default());
1289        let app = FederationApp::new(catalog, health, circuit_breaker);
1290
1291        // Default policies: health, latency, privacy, locality, cost
1292        assert_eq!(app.policies.len(), 5);
1293        assert_eq!(app.policies[0].name, "health");
1294        assert_eq!(app.policies[1].name, "latency");
1295        assert_eq!(app.policies[2].name, "privacy");
1296        assert_eq!(app.policies[3].name, "locality");
1297        assert_eq!(app.policies[4].name, "cost");
1298    }
1299
1300    #[test]
1301    fn test_federation_app_max_history_default() {
1302        let catalog = Arc::new(ModelCatalog::new());
1303        let health = Arc::new(HealthChecker::default());
1304        let circuit_breaker = Arc::new(CircuitBreaker::default());
1305        let app = FederationApp::new(catalog, health, circuit_breaker);
1306
1307        assert_eq!(app.max_history, 100);
1308    }
1309
1310    // =========================================================================
1311    // Probar TUI Frame Tests
1312    // =========================================================================
1313
1314    mod tui_frame_tests {
1315        use super::*;
1316        use jugar_probar::tui::{
1317            expect_frame, FrameSequence, SnapshotManager, TuiFrame, TuiSnapshot,
1318        };
1319        use ratatui::backend::TestBackend;
1320        use ratatui::Terminal;
1321
1322        /// Helper to render federation app to a test backend
1323        fn render_frame(app: &FederationApp, width: u16, height: u16) -> TuiFrame {
1324            let backend = TestBackend::new(width, height);
1325            let mut terminal = Terminal::new(backend).expect("terminal creation");
1326            terminal
1327                .draw(|f| render_federation_dashboard(f, app))
1328                .expect("draw");
1329            TuiFrame::from_buffer(terminal.backend().buffer(), 0)
1330        }
1331
1332        fn create_test_app() -> FederationApp {
1333            let catalog = Arc::new(ModelCatalog::new());
1334            let health = Arc::new(HealthChecker::default());
1335            let circuit_breaker = Arc::new(CircuitBreaker::default());
1336            FederationApp::new(catalog, health, circuit_breaker)
1337        }
1338
1339        #[test]
1340        fn test_federation_tui_title_displayed() {
1341            let app = create_test_app();
1342            let frame = render_frame(&app, 100, 30);
1343
1344            assert!(
1345                frame.contains("Federation Gateway"),
1346                "Should show title: {}",
1347                frame.as_text()
1348            );
1349        }
1350
1351        #[test]
1352        fn test_federation_tui_all_tabs_displayed() {
1353            let app = create_test_app();
1354            let frame = render_frame(&app, 100, 30);
1355
1356            assert!(frame.contains("Catalog"), "Should show Catalog tab");
1357            assert!(frame.contains("Health"), "Should show Health tab");
1358            assert!(frame.contains("Routing"), "Should show Routing tab");
1359            assert!(frame.contains("Circuits"), "Should show Circuits tab");
1360            assert!(frame.contains("Stats"), "Should show Stats tab");
1361            assert!(frame.contains("Policies"), "Should show Policies tab");
1362            assert!(frame.contains("Help"), "Should show Help tab");
1363        }
1364
1365        #[test]
1366        fn test_federation_tui_catalog_empty() {
1367            let app = create_test_app();
1368            let frame = render_frame(&app, 100, 30);
1369
1370            // Default tab is catalog, should show empty message
1371            assert!(
1372                frame.contains("No models registered") || frame.contains("MODEL CATALOG"),
1373                "Should show catalog panel"
1374            );
1375        }
1376
1377        #[test]
1378        fn test_federation_tui_health_tab() {
1379            let mut app = create_test_app();
1380            app.current_tab = FederationTab::Health;
1381            let frame = render_frame(&app, 100, 30);
1382
1383            assert!(
1384                frame.contains("NODE HEALTH"),
1385                "Should show health panel title"
1386            );
1387        }
1388
1389        #[test]
1390        fn test_federation_tui_routing_tab() {
1391            let mut app = create_test_app();
1392            app.current_tab = FederationTab::Routing;
1393            let frame = render_frame(&app, 100, 30);
1394
1395            assert!(
1396                frame.contains("ROUTING DECISIONS"),
1397                "Should show routing panel title"
1398            );
1399        }
1400
1401        #[test]
1402        fn test_federation_tui_circuits_tab() {
1403            let mut app = create_test_app();
1404            app.current_tab = FederationTab::Circuits;
1405            let frame = render_frame(&app, 100, 30);
1406
1407            assert!(
1408                frame.contains("CIRCUIT BREAKERS"),
1409                "Should show circuits panel title"
1410            );
1411        }
1412
1413        #[test]
1414        fn test_federation_tui_stats_tab() {
1415            let mut app = create_test_app();
1416            app.current_tab = FederationTab::Stats;
1417            let frame = render_frame(&app, 100, 30);
1418
1419            assert!(
1420                frame.contains("GATEWAY STATS"),
1421                "Should show stats panel title"
1422            );
1423            assert!(
1424                frame.contains("Total Requests"),
1425                "Should show request count"
1426            );
1427        }
1428
1429        #[test]
1430        fn test_federation_tui_policies_tab() {
1431            let mut app = create_test_app();
1432            app.current_tab = FederationTab::Policies;
1433            let frame = render_frame(&app, 100, 30);
1434
1435            assert!(
1436                frame.contains("ACTIVE POLICIES"),
1437                "Should show policies panel title"
1438            );
1439            assert!(frame.contains("health"), "Should show health policy");
1440            assert!(frame.contains("latency"), "Should show latency policy");
1441        }
1442
1443        #[test]
1444        fn test_federation_tui_help_tab() {
1445            let mut app = create_test_app();
1446            app.current_tab = FederationTab::Help;
1447            let frame = render_frame(&app, 100, 30);
1448
1449            assert!(
1450                frame.contains("Keyboard Shortcuts"),
1451                "Should show keyboard shortcuts"
1452            );
1453            assert!(frame.contains("q / Esc"), "Should show quit shortcut");
1454        }
1455
1456        #[test]
1457        fn test_federation_tui_frame_dimensions() {
1458            let app = create_test_app();
1459            let frame = render_frame(&app, 120, 40);
1460
1461            assert_eq!(frame.width(), 120, "Frame width");
1462            assert_eq!(frame.height(), 40, "Frame height");
1463        }
1464
1465        // =====================================================================
1466        // Playwright-style Assertions
1467        // =====================================================================
1468
1469        #[test]
1470        fn test_probar_chained_assertions() {
1471            let app = create_test_app();
1472            let frame = render_frame(&app, 100, 30);
1473
1474            let mut assertion = expect_frame(&frame);
1475            assert!(assertion.to_contain_text("Federation Gateway").is_ok());
1476            assert!(assertion.to_contain_text("Navigation").is_ok());
1477            assert!(assertion.not_to_contain_text("ERROR").is_ok());
1478        }
1479
1480        #[test]
1481        fn test_probar_soft_assertions() {
1482            let app = create_test_app();
1483            let frame = render_frame(&app, 100, 30);
1484
1485            let mut assertion = expect_frame(&frame).soft();
1486            let _ = assertion.to_contain_text("Federation Gateway");
1487            let _ = assertion.to_contain_text("Catalog");
1488            let _ = assertion.to_have_size(100, 30);
1489
1490            assert!(assertion.errors().is_empty(), "No soft assertion errors");
1491            assert!(assertion.finalize().is_ok());
1492        }
1493
1494        // =====================================================================
1495        // Snapshot Testing
1496        // =====================================================================
1497
1498        #[test]
1499        fn test_snapshot_creation() {
1500            let app = create_test_app();
1501            let frame = render_frame(&app, 100, 30);
1502
1503            let snapshot = TuiSnapshot::from_frame("federation_catalog", &frame);
1504
1505            assert_eq!(snapshot.name, "federation_catalog");
1506            assert_eq!(snapshot.width, 100);
1507            assert_eq!(snapshot.height, 30);
1508            assert!(!snapshot.hash.is_empty());
1509        }
1510
1511        #[test]
1512        fn test_snapshot_different_tabs_differ() {
1513            let mut app = create_test_app();
1514
1515            app.current_tab = FederationTab::Catalog;
1516            let frame_catalog = render_frame(&app, 100, 30);
1517            let snap_catalog = TuiSnapshot::from_frame("catalog", &frame_catalog);
1518
1519            app.current_tab = FederationTab::Help;
1520            let frame_help = render_frame(&app, 100, 30);
1521            let snap_help = TuiSnapshot::from_frame("help", &frame_help);
1522
1523            assert!(
1524                !snap_catalog.matches(&snap_help),
1525                "Different tabs should have different snapshots"
1526            );
1527        }
1528
1529        // =====================================================================
1530        // Frame Sequence Testing
1531        // =====================================================================
1532
1533        #[test]
1534        fn test_frame_sequence_tab_navigation() {
1535            let mut app = create_test_app();
1536            let mut sequence = FrameSequence::new("federation_tabs");
1537
1538            for tab in [
1539                FederationTab::Catalog,
1540                FederationTab::Health,
1541                FederationTab::Routing,
1542                FederationTab::Circuits,
1543                FederationTab::Stats,
1544                FederationTab::Policies,
1545                FederationTab::Help,
1546            ] {
1547                app.current_tab = tab;
1548                sequence.add_frame(&render_frame(&app, 100, 30));
1549            }
1550
1551            assert_eq!(sequence.len(), 7, "Should have 7 frames");
1552
1553            let first = sequence.first().expect("first");
1554            let last = sequence.last().expect("last");
1555            assert!(!first.matches(last), "First and last should differ");
1556        }
1557
1558        // =====================================================================
1559        // Snapshot Manager Tests
1560        // =====================================================================
1561
1562        #[test]
1563        fn test_snapshot_manager_workflow() {
1564            use tempfile::TempDir;
1565
1566            let temp_dir = TempDir::new().expect("temp dir");
1567            let manager = SnapshotManager::new(temp_dir.path());
1568
1569            let app = create_test_app();
1570            let frame = render_frame(&app, 100, 30);
1571
1572            let result = manager.assert_snapshot("federation_dashboard", &frame);
1573            assert!(result.is_ok(), "First snapshot should be created");
1574            assert!(
1575                manager.exists("federation_dashboard"),
1576                "Snapshot should exist"
1577            );
1578
1579            let result2 = manager.assert_snapshot("federation_dashboard", &frame);
1580            assert!(result2.is_ok(), "Same frame should match");
1581        }
1582
1583        // =====================================================================
1584        // UX Coverage Tests
1585        // =====================================================================
1586
1587        #[test]
1588        fn test_ux_coverage_federation_elements() {
1589            use jugar_probar::ux_coverage::{InteractionType, StateId, UxCoverageBuilder};
1590
1591            let mut tracker = UxCoverageBuilder::new()
1592                .clickable("tab", "catalog")
1593                .clickable("tab", "health")
1594                .clickable("tab", "routing")
1595                .clickable("tab", "circuits")
1596                .clickable("tab", "stats")
1597                .clickable("tab", "policies")
1598                .clickable("tab", "help")
1599                .clickable("nav", "next_tab")
1600                .clickable("nav", "prev_tab")
1601                .clickable("nav", "next_row")
1602                .clickable("nav", "prev_row")
1603                .clickable("nav", "quit")
1604                .screen("catalog")
1605                .screen("health")
1606                .screen("routing")
1607                .screen("circuits")
1608                .screen("stats")
1609                .screen("policies")
1610                .screen("help")
1611                .build();
1612
1613            let mut app = create_test_app();
1614
1615            // Cover all tabs
1616            for (tab, name) in [
1617                (FederationTab::Catalog, "catalog"),
1618                (FederationTab::Health, "health"),
1619                (FederationTab::Routing, "routing"),
1620                (FederationTab::Circuits, "circuits"),
1621                (FederationTab::Stats, "stats"),
1622                (FederationTab::Policies, "policies"),
1623                (FederationTab::Help, "help"),
1624            ] {
1625                app.current_tab = tab;
1626                tracker.record_interaction(
1627                    &jugar_probar::ux_coverage::ElementId::new("tab", name),
1628                    InteractionType::Click,
1629                );
1630                tracker.record_state(StateId::new("screen", name));
1631            }
1632
1633            // Cover navigation
1634            app.next_tab();
1635            tracker.record_interaction(
1636                &jugar_probar::ux_coverage::ElementId::new("nav", "next_tab"),
1637                InteractionType::Click,
1638            );
1639
1640            app.prev_tab();
1641            tracker.record_interaction(
1642                &jugar_probar::ux_coverage::ElementId::new("nav", "prev_tab"),
1643                InteractionType::Click,
1644            );
1645
1646            app.select_next();
1647            tracker.record_interaction(
1648                &jugar_probar::ux_coverage::ElementId::new("nav", "next_row"),
1649                InteractionType::Click,
1650            );
1651
1652            app.select_prev();
1653            tracker.record_interaction(
1654                &jugar_probar::ux_coverage::ElementId::new("nav", "prev_row"),
1655                InteractionType::Click,
1656            );
1657
1658            app.should_quit = true;
1659            tracker.record_interaction(
1660                &jugar_probar::ux_coverage::ElementId::new("nav", "quit"),
1661                InteractionType::Click,
1662            );
1663
1664            let report = tracker.generate_report();
1665            println!("{}", report.summary());
1666
1667            assert!(report.is_complete, "UX coverage must be COMPLETE");
1668            assert!(tracker.meets(100.0), "Coverage: {}", tracker.summary());
1669        }
1670
1671        #[test]
1672        fn test_ux_coverage_with_gui_macro() {
1673            use jugar_probar::gui_coverage;
1674
1675            let mut gui = gui_coverage! {
1676                buttons: [
1677                    "tab_catalog", "tab_health", "tab_routing",
1678                    "tab_circuits", "tab_stats", "tab_policies", "tab_help",
1679                    "nav_next", "nav_prev", "quit"
1680                ],
1681                screens: [
1682                    "catalog", "health", "routing",
1683                    "circuits", "stats", "policies", "help"
1684                ]
1685            };
1686
1687            // Cover all buttons
1688            gui.click("tab_catalog");
1689            gui.click("tab_health");
1690            gui.click("tab_routing");
1691            gui.click("tab_circuits");
1692            gui.click("tab_stats");
1693            gui.click("tab_policies");
1694            gui.click("tab_help");
1695            gui.click("nav_next");
1696            gui.click("nav_prev");
1697            gui.click("quit");
1698
1699            // Cover all screens
1700            gui.visit("catalog");
1701            gui.visit("health");
1702            gui.visit("routing");
1703            gui.visit("circuits");
1704            gui.visit("stats");
1705            gui.visit("policies");
1706            gui.visit("help");
1707
1708            assert!(gui.is_complete(), "Should have 100% coverage");
1709            assert!(gui.meets(100.0), "Coverage: {}", gui.summary());
1710
1711            let report = gui.generate_report();
1712            println!(
1713                "Federation UX Coverage:\n  Elements: {}/{}\n  States: {}/{}\n  Overall: {:.1}%",
1714                report.covered_elements,
1715                report.total_elements,
1716                report.covered_states,
1717                report.total_states,
1718                report.overall_coverage * 100.0
1719            );
1720        }
1721    }
1722}