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        Self::Verification,
120        Self::Analytics,
121        Self::Recorder,
122        Self::Import,
123        Self::Audit,
124        Self::WorldState,
125        Self::ContractDiff,
126        Self::Federation,
127        Self::BehavioralCloning,
128        Self::Conformance,
129    ];
130
131    pub fn label(self) -> &'static str {
132        match self {
133            Self::Dashboard => "Dashboard",
134            Self::Logs => "Logs",
135            Self::Routes => "Routes",
136            Self::Metrics => "Metrics",
137            Self::Config => "Config",
138            Self::Chaos => "Chaos",
139            Self::Workspaces => "Workspaces",
140            Self::Plugins => "Plugins",
141            Self::Fixtures => "Fixtures",
142            Self::Health => "Health",
143            Self::SmokeTests => "Smoke Tests",
144            Self::TimeTravel => "Time Travel",
145            Self::Chains => "Chains",
146            Self::Verification => "Verification",
147            Self::Analytics => "Analytics",
148            Self::Recorder => "Recorder",
149            Self::Import => "Import",
150            Self::Audit => "Audit",
151            Self::WorldState => "World State",
152            Self::ContractDiff => "Contract Diff",
153            Self::Federation => "Federation",
154            Self::BehavioralCloning => "VBR",
155            Self::Conformance => "Conformance",
156        }
157    }
158
159    pub fn data_key(self) -> &'static str {
160        match self {
161            Self::Dashboard => "dashboard",
162            Self::Logs => "logs",
163            Self::Routes => "routes",
164            Self::Metrics => "metrics",
165            Self::Config => "config",
166            Self::Chaos => "chaos",
167            Self::Workspaces => "workspaces",
168            Self::Plugins => "plugins",
169            Self::Fixtures => "fixtures",
170            Self::Health => "health",
171            Self::SmokeTests => "smoke_tests",
172            Self::TimeTravel => "time_travel",
173            Self::Chains => "chains",
174            Self::Verification => "verification",
175            Self::Analytics => "analytics",
176            Self::Recorder => "recorder",
177            Self::Import => "import",
178            Self::Audit => "audit",
179            Self::WorldState => "world_state",
180            Self::ContractDiff => "contract_diff",
181            Self::Federation => "federation",
182            Self::BehavioralCloning => "behavioral_cloning",
183            Self::Conformance => "conformance",
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn all_has_correct_count() {
194        // Count the enum variants: 23 total (Conformance added in #79 round 12)
195        assert_eq!(ScreenId::ALL.len(), 23);
196    }
197
198    #[test]
199    fn all_labels_are_non_empty() {
200        for screen_id in ScreenId::ALL {
201            let label = screen_id.label();
202            assert!(!label.is_empty(), "label() returned empty string for {screen_id:?}");
203        }
204    }
205
206    #[test]
207    fn all_data_keys_are_non_empty() {
208        for screen_id in ScreenId::ALL {
209            let key = screen_id.data_key();
210            assert!(!key.is_empty(), "data_key() returned empty string for {screen_id:?}");
211        }
212    }
213
214    #[test]
215    fn all_data_keys_are_snake_case() {
216        for screen_id in ScreenId::ALL {
217            let key = screen_id.data_key();
218            assert!(
219                !key.contains(' ') && !key.contains('-'),
220                "data_key() for {screen_id:?} contains spaces or hyphens: {key}"
221            );
222            assert_eq!(
223                key,
224                key.to_lowercase(),
225                "data_key() for {screen_id:?} is not lowercase: {key}"
226            );
227        }
228    }
229
230    #[test]
231    fn all_data_keys_are_unique() {
232        let keys: Vec<&str> = ScreenId::ALL.iter().map(|s| s.data_key()).collect();
233        let mut deduped = keys.clone();
234        deduped.sort_unstable();
235        deduped.dedup();
236        assert_eq!(keys.len(), deduped.len(), "Duplicate data_key() values found");
237    }
238
239    #[test]
240    fn all_labels_and_data_keys_consistent_count() {
241        let label_count = ScreenId::ALL.iter().filter(|s| !s.label().is_empty()).count();
242        let key_count = ScreenId::ALL.iter().filter(|s| !s.data_key().is_empty()).count();
243        assert_eq!(label_count, key_count);
244        assert_eq!(label_count, ScreenId::ALL.len());
245    }
246
247    #[test]
248    fn specific_labels() {
249        assert_eq!(ScreenId::Dashboard.label(), "Dashboard");
250        assert_eq!(ScreenId::Logs.label(), "Logs");
251        assert_eq!(ScreenId::SmokeTests.label(), "Smoke Tests");
252        assert_eq!(ScreenId::TimeTravel.label(), "Time Travel");
253        assert_eq!(ScreenId::WorldState.label(), "World State");
254        assert_eq!(ScreenId::ContractDiff.label(), "Contract Diff");
255        assert_eq!(ScreenId::BehavioralCloning.label(), "VBR");
256    }
257
258    #[test]
259    fn specific_data_keys() {
260        assert_eq!(ScreenId::Dashboard.data_key(), "dashboard");
261        assert_eq!(ScreenId::SmokeTests.data_key(), "smoke_tests");
262        assert_eq!(ScreenId::TimeTravel.data_key(), "time_travel");
263        assert_eq!(ScreenId::WorldState.data_key(), "world_state");
264        assert_eq!(ScreenId::ContractDiff.data_key(), "contract_diff");
265        assert_eq!(ScreenId::BehavioralCloning.data_key(), "behavioral_cloning");
266    }
267
268    #[test]
269    fn screen_id_equality() {
270        assert_eq!(ScreenId::Dashboard, ScreenId::Dashboard);
271        assert_ne!(ScreenId::Dashboard, ScreenId::Logs);
272    }
273
274    #[test]
275    fn screen_id_clone() {
276        let id = ScreenId::Dashboard;
277        let cloned = id;
278        assert_eq!(id, cloned);
279    }
280}