1use 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#[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#[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#[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#[derive(Debug, Clone)]
101pub struct PolicyDisplay {
102 pub name: String,
103 pub weight: f64,
104 pub enabled: bool,
105 pub description: String,
106}
107
108pub struct FederationApp {
110 pub current_tab: FederationTab,
112 pub catalog: Arc<ModelCatalog>,
114 pub health: Arc<HealthChecker>,
116 pub circuit_breaker: Arc<CircuitBreaker>,
118 pub gateway: Option<Arc<FederationGateway>>,
120 pub routing_history: Vec<RoutingRecord>,
122 pub max_history: usize,
124 pub policies: Vec<PolicyDisplay>,
126 pub should_quit: bool,
128 pub status_message: Option<String>,
130 pub selected_row: usize,
132}
133
134impl FederationApp {
135 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 #[must_use]
191 pub fn with_gateway(mut self, gateway: Arc<FederationGateway>) -> Self {
192 self.gateway = Some(gateway);
193 self
194 }
195
196 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 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 pub fn select_next(&mut self) {
213 self.selected_row = self.selected_row.saturating_add(1);
214 }
215
216 pub fn select_prev(&mut self) {
218 self.selected_row = self.selected_row.saturating_sub(1);
219 }
220
221 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 pub fn healthy_node_count(&self) -> usize {
231 self.health.healthy_count()
232 }
233
234 pub fn total_node_count(&self) -> usize {
236 self.health.total_count()
237 }
238
239 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 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) }
257}
258
259pub fn render_federation_dashboard(f: &mut Frame<'_>, app: &FederationApp) {
265 let chunks = Layout::default()
266 .direction(Direction::Vertical)
267 .constraints([
268 Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
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("-"), 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 let chunks = Layout::default()
594 .direction(Direction::Vertical)
595 .constraints([
596 Constraint::Length(8), Constraint::Length(3), Constraint::Min(0), ])
600 .split(area);
601
602 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 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#[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 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(); 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 app.current_tab = FederationTab::Help;
1213 app.next_tab();
1214 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 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 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 assert_eq!(app.routing_history.len(), 3);
1254 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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}