Skip to main content

mockforge_tui/screens/
mod.rs

1//! Screen trait and registry — each screen owns its own state and renders
2//! into the main content area.
3
4pub mod analytics;
5pub mod audit;
6pub mod behavioral_cloning;
7pub mod chains;
8pub mod chaos;
9pub mod config;
10pub mod conformance;
11pub mod contract_diff;
12pub mod dashboard;
13pub mod federation;
14pub mod fixtures;
15pub mod health;
16pub mod import;
17pub mod logs;
18pub mod metrics;
19pub mod plugins;
20pub mod recorder;
21pub mod routes;
22pub mod smoke_tests;
23pub mod time_travel;
24pub mod verification;
25pub mod workspaces;
26pub mod world_state;
27
28use crossterm::event::KeyEvent;
29use ratatui::layout::Rect;
30use ratatui::Frame;
31
32use crate::api::client::MockForgeClient;
33use crate::event::Event;
34use tokio::sync::mpsc;
35
36/// Every screen implements this trait.
37pub trait Screen: Send {
38    /// Display name shown in the tab bar.
39    fn title(&self) -> &str;
40
41    /// Handle a key event. Return `true` if the event was consumed.
42    fn handle_key(&mut self, key: KeyEvent) -> bool;
43
44    /// Render into the given area.
45    fn render(&self, frame: &mut Frame, area: Rect);
46
47    /// Called on tick to refresh data if needed. The screen can spawn
48    /// background fetches via the event sender.
49    fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>);
50
51    /// Ingest an event payload pushed by a background data fetcher.
52    fn on_data(&mut self, payload: &str);
53
54    /// Ingest an API error for this screen.
55    fn on_error(&mut self, message: &str);
56
57    /// Hint text for the status bar (screen-specific key hints).
58    fn status_hint(&self) -> &str {
59        ""
60    }
61
62    /// Return the current error message, if any. Used by the app to render
63    /// a persistent error banner while still showing stale data.
64    fn error(&self) -> Option<&str> {
65        None
66    }
67
68    /// Reset internal fetch timer so data is re-fetched on the next tick.
69    fn force_refresh(&mut self) {}
70
71    /// Push a single log line (only meaningful for the Logs screen).
72    fn push_log_line(&mut self, _line: String) {}
73}
74
75/// Screen identifiers used for tab ordering and data routing.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ScreenId {
78    Dashboard,
79    Logs,
80    Routes,
81    Metrics,
82    Config,
83    Chaos,
84    Workspaces,
85    Plugins,
86    Fixtures,
87    Health,
88    SmokeTests,
89    TimeTravel,
90    Chains,
91    Verification,
92    Analytics,
93    Recorder,
94    Import,
95    Audit,
96    WorldState,
97    ContractDiff,
98    Federation,
99    BehavioralCloning,
100    Conformance,
101}
102
103impl ScreenId {
104    /// All screens in tab order.
105    pub const ALL: &[Self] = &[
106        Self::Dashboard,
107        Self::Logs,
108        Self::Routes,
109        Self::Metrics,
110        Self::Config,
111        Self::Chaos,
112        Self::Workspaces,
113        Self::Plugins,
114        Self::Fixtures,
115        Self::Health,
116        Self::SmokeTests,
117        Self::TimeTravel,
118        Self::Chains,
119        // Conformance sits before Verification — Verification's `Tab`
120        // is consumed to cycle internal fields, so it acts as a
121        // dead-end for tab navigation. Putting Conformance earlier
122        // keeps it reachable via plain Tab without forcing the user
123        // to click-and-tab around the stuck Verification screen.
124        // (Reordered after Srikanth flagged it on Issue #79 round 12.)
125        Self::Conformance,
126        Self::Verification,
127        Self::Analytics,
128        Self::Recorder,
129        Self::Import,
130        Self::Audit,
131        Self::WorldState,
132        Self::ContractDiff,
133        Self::Federation,
134        Self::BehavioralCloning,
135    ];
136
137    pub fn label(self) -> &'static str {
138        match self {
139            Self::Dashboard => "Dashboard",
140            Self::Logs => "Logs",
141            Self::Routes => "Routes",
142            Self::Metrics => "Metrics",
143            Self::Config => "Config",
144            Self::Chaos => "Chaos",
145            Self::Workspaces => "Workspaces",
146            Self::Plugins => "Plugins",
147            Self::Fixtures => "Fixtures",
148            Self::Health => "Health",
149            Self::SmokeTests => "Smoke Tests",
150            Self::TimeTravel => "Time Travel",
151            Self::Chains => "Chains",
152            Self::Verification => "Verification",
153            Self::Analytics => "Analytics",
154            Self::Recorder => "Recorder",
155            Self::Import => "Import",
156            Self::Audit => "Audit",
157            Self::WorldState => "World State",
158            Self::ContractDiff => "Contract Diff",
159            Self::Federation => "Federation",
160            Self::BehavioralCloning => "VBR",
161            Self::Conformance => "Conformance",
162        }
163    }
164
165    pub fn data_key(self) -> &'static str {
166        match self {
167            Self::Dashboard => "dashboard",
168            Self::Logs => "logs",
169            Self::Routes => "routes",
170            Self::Metrics => "metrics",
171            Self::Config => "config",
172            Self::Chaos => "chaos",
173            Self::Workspaces => "workspaces",
174            Self::Plugins => "plugins",
175            Self::Fixtures => "fixtures",
176            Self::Health => "health",
177            Self::SmokeTests => "smoke_tests",
178            Self::TimeTravel => "time_travel",
179            Self::Chains => "chains",
180            Self::Verification => "verification",
181            Self::Analytics => "analytics",
182            Self::Recorder => "recorder",
183            Self::Import => "import",
184            Self::Audit => "audit",
185            Self::WorldState => "world_state",
186            Self::ContractDiff => "contract_diff",
187            Self::Federation => "federation",
188            Self::BehavioralCloning => "behavioral_cloning",
189            Self::Conformance => "conformance",
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn all_has_correct_count() {
200        // Count the enum variants: 23 total (Conformance added in #79 round 12)
201        assert_eq!(ScreenId::ALL.len(), 23);
202    }
203
204    #[test]
205    fn all_labels_are_non_empty() {
206        for screen_id in ScreenId::ALL {
207            let label = screen_id.label();
208            assert!(!label.is_empty(), "label() returned empty string for {screen_id:?}");
209        }
210    }
211
212    #[test]
213    fn all_data_keys_are_non_empty() {
214        for screen_id in ScreenId::ALL {
215            let key = screen_id.data_key();
216            assert!(!key.is_empty(), "data_key() returned empty string for {screen_id:?}");
217        }
218    }
219
220    #[test]
221    fn all_data_keys_are_snake_case() {
222        for screen_id in ScreenId::ALL {
223            let key = screen_id.data_key();
224            assert!(
225                !key.contains(' ') && !key.contains('-'),
226                "data_key() for {screen_id:?} contains spaces or hyphens: {key}"
227            );
228            assert_eq!(
229                key,
230                key.to_lowercase(),
231                "data_key() for {screen_id:?} is not lowercase: {key}"
232            );
233        }
234    }
235
236    #[test]
237    fn all_data_keys_are_unique() {
238        let keys: Vec<&str> = ScreenId::ALL.iter().map(|s| s.data_key()).collect();
239        let mut deduped = keys.clone();
240        deduped.sort_unstable();
241        deduped.dedup();
242        assert_eq!(keys.len(), deduped.len(), "Duplicate data_key() values found");
243    }
244
245    #[test]
246    fn all_labels_and_data_keys_consistent_count() {
247        let label_count = ScreenId::ALL.iter().filter(|s| !s.label().is_empty()).count();
248        let key_count = ScreenId::ALL.iter().filter(|s| !s.data_key().is_empty()).count();
249        assert_eq!(label_count, key_count);
250        assert_eq!(label_count, ScreenId::ALL.len());
251    }
252
253    #[test]
254    fn specific_labels() {
255        assert_eq!(ScreenId::Dashboard.label(), "Dashboard");
256        assert_eq!(ScreenId::Logs.label(), "Logs");
257        assert_eq!(ScreenId::SmokeTests.label(), "Smoke Tests");
258        assert_eq!(ScreenId::TimeTravel.label(), "Time Travel");
259        assert_eq!(ScreenId::WorldState.label(), "World State");
260        assert_eq!(ScreenId::ContractDiff.label(), "Contract Diff");
261        assert_eq!(ScreenId::BehavioralCloning.label(), "VBR");
262    }
263
264    #[test]
265    fn specific_data_keys() {
266        assert_eq!(ScreenId::Dashboard.data_key(), "dashboard");
267        assert_eq!(ScreenId::SmokeTests.data_key(), "smoke_tests");
268        assert_eq!(ScreenId::TimeTravel.data_key(), "time_travel");
269        assert_eq!(ScreenId::WorldState.data_key(), "world_state");
270        assert_eq!(ScreenId::ContractDiff.data_key(), "contract_diff");
271        assert_eq!(ScreenId::BehavioralCloning.data_key(), "behavioral_cloning");
272    }
273
274    #[test]
275    fn screen_id_equality() {
276        assert_eq!(ScreenId::Dashboard, ScreenId::Dashboard);
277        assert_ne!(ScreenId::Dashboard, ScreenId::Logs);
278    }
279
280    #[test]
281    fn screen_id_clone() {
282        let id = ScreenId::Dashboard;
283        let cloned = id;
284        assert_eq!(id, cloned);
285    }
286}