Skip to main content

mockforge_tui/screens/
config.rs

1//! Config screen — two-pane layout with categories on left and details on right.
2//! Supports inline editing of boolean, numeric, and string fields.
3
4use std::collections::HashMap;
5use std::time::Instant;
6
7use crossterm::event::{KeyCode, KeyEvent};
8use ratatui::{
9    layout::{Constraint, Layout, Rect},
10    style::{Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, Paragraph},
13    Frame,
14};
15use tokio::sync::mpsc;
16
17use crate::api::client::MockForgeClient;
18use crate::api::models::{ConfigState, FaultConfig, LatencyConfig, ProxyConfig};
19use crate::event::Event;
20use crate::screens::Screen;
21use crate::theme::Theme;
22
23const CATEGORIES: &[&str] = &[
24    "Latency",
25    "Faults",
26    "Proxy",
27    "Traffic Shaping",
28    "Validation",
29];
30
31/// Describes a single editable field within a config category.
32#[derive(Clone, Copy)]
33enum FieldKind {
34    Bool,
35    Uint,
36    Float,
37    Str,
38    ReadOnly,
39}
40
41struct FieldDef {
42    name: &'static str,
43    json_key: &'static str,
44    kind: FieldKind,
45}
46
47/// Returns the field definitions for each config category.
48fn fields_for_category(cat: usize) -> &'static [FieldDef] {
49    match cat {
50        0 => &[
51            FieldDef {
52                name: "Enabled",
53                json_key: "enabled",
54                kind: FieldKind::Bool,
55            },
56            FieldDef {
57                name: "Base Latency (ms)",
58                json_key: "base_ms",
59                kind: FieldKind::Uint,
60            },
61            FieldDef {
62                name: "Jitter (ms)",
63                json_key: "jitter_ms",
64                kind: FieldKind::Uint,
65            },
66        ],
67        1 => &[
68            FieldDef {
69                name: "Enabled",
70                json_key: "enabled",
71                kind: FieldKind::Bool,
72            },
73            FieldDef {
74                name: "Failure Rate",
75                json_key: "failure_rate",
76                kind: FieldKind::Float,
77            },
78            FieldDef {
79                name: "Status Codes",
80                json_key: "status_codes",
81                kind: FieldKind::ReadOnly,
82            },
83        ],
84        2 => &[
85            FieldDef {
86                name: "Enabled",
87                json_key: "enabled",
88                kind: FieldKind::Bool,
89            },
90            FieldDef {
91                name: "Upstream URL",
92                json_key: "upstream_url",
93                kind: FieldKind::Str,
94            },
95            FieldDef {
96                name: "Timeout (s)",
97                json_key: "timeout_seconds",
98                kind: FieldKind::Uint,
99            },
100        ],
101        3 => &[
102            FieldDef {
103                name: "Enabled",
104                json_key: "enabled",
105                kind: FieldKind::ReadOnly,
106            },
107            FieldDef {
108                name: "Bandwidth",
109                json_key: "bandwidth",
110                kind: FieldKind::ReadOnly,
111            },
112            FieldDef {
113                name: "Burst Loss",
114                json_key: "burst_loss",
115                kind: FieldKind::ReadOnly,
116            },
117        ],
118        4 => &[
119            FieldDef {
120                name: "Mode",
121                json_key: "mode",
122                kind: FieldKind::ReadOnly,
123            },
124            FieldDef {
125                name: "Aggregate Errors",
126                json_key: "aggregate_errors",
127                kind: FieldKind::ReadOnly,
128            },
129            FieldDef {
130                name: "Validate Responses",
131                json_key: "validate_responses",
132                kind: FieldKind::ReadOnly,
133            },
134        ],
135        _ => &[],
136    }
137}
138
139enum PendingMutation {
140    Latency(LatencyConfig),
141    Faults(FaultConfig),
142    Proxy(ProxyConfig),
143}
144
145pub struct ConfigScreen {
146    data: Option<serde_json::Value>,
147    error: Option<String>,
148    last_fetch: Option<Instant>,
149    selected_category: usize,
150    selected_field: usize,
151    editing: bool,
152    /// When Some, an inline text editor is active for the current field.
153    input_buf: Option<String>,
154    input_cursor: usize,
155    pending_mutation: Option<PendingMutation>,
156}
157
158impl ConfigScreen {
159    pub fn new() -> Self {
160        Self {
161            data: None,
162            error: None,
163            last_fetch: None,
164            selected_category: 0,
165            selected_field: 0,
166            editing: false,
167            input_buf: None,
168            input_cursor: 0,
169            pending_mutation: None,
170        }
171    }
172
173    fn category_key(&self) -> &'static str {
174        match self.selected_category {
175            0 => "latency",
176            1 => "faults",
177            2 => "proxy",
178            3 => "traffic_shaping",
179            4 => "validation",
180            _ => "latency",
181        }
182    }
183
184    fn field_count(&self) -> usize {
185        fields_for_category(self.selected_category).len()
186    }
187
188    fn current_field(&self) -> Option<&'static FieldDef> {
189        fields_for_category(self.selected_category).get(self.selected_field)
190    }
191
192    /// Read the current value of the selected field from local data.
193    fn read_field_value(&self, json_key: &str) -> Option<serde_json::Value> {
194        self.data
195            .as_ref()
196            .and_then(|d| d.get(self.category_key()))
197            .and_then(|s| s.get(json_key))
198            .cloned()
199    }
200
201    /// Start inline editing for the current field.
202    fn start_input(&mut self) {
203        let Some(field) = self.current_field() else {
204            return;
205        };
206        let current = self.read_field_value(field.json_key);
207        let text = match current {
208            Some(serde_json::Value::String(s)) => s,
209            Some(serde_json::Value::Number(n)) => n.to_string(),
210            Some(serde_json::Value::Null) => String::new(),
211            Some(v) => v.to_string(),
212            None => String::new(),
213        };
214        self.input_cursor = text.len();
215        self.input_buf = Some(text);
216    }
217
218    /// Commit the inline edit and build a mutation.
219    fn commit_input(&mut self) {
220        let Some(buf) = self.input_buf.take() else {
221            return;
222        };
223        let Some(field) = self.current_field() else {
224            return;
225        };
226        let key = self.category_key();
227
228        // Parse the input into the appropriate JSON value.
229        let new_value = match field.kind {
230            FieldKind::Uint => {
231                let Ok(n) = buf.trim().parse::<u64>() else {
232                    return;
233                };
234                serde_json::Value::Number(n.into())
235            }
236            FieldKind::Float => {
237                let Ok(f) = buf.trim().parse::<f64>() else {
238                    return;
239                };
240                let Some(n) = serde_json::Number::from_f64(f) else {
241                    return;
242                };
243                serde_json::Value::Number(n)
244            }
245            FieldKind::Str => serde_json::Value::String(buf),
246            _ => return,
247        };
248
249        // Optimistically update local state.
250        if let Some(ref mut data) = self.data {
251            if let Some(section) = data.get_mut(key) {
252                section[field.json_key] = new_value;
253            }
254        }
255
256        self.build_mutation();
257    }
258
259    /// Toggle a boolean field.
260    fn toggle_bool(&mut self) {
261        let Some(field) = self.current_field() else {
262            return;
263        };
264        let key = self.category_key();
265        let current =
266            self.read_field_value(field.json_key).and_then(|v| v.as_bool()).unwrap_or(false);
267        let new_val = !current;
268
269        // Optimistically update local state.
270        if let Some(ref mut data) = self.data {
271            if let Some(section) = data.get_mut(key) {
272                section[field.json_key] = serde_json::Value::Bool(new_val);
273            }
274        }
275
276        self.build_mutation();
277    }
278
279    /// Build a pending mutation from the current local data for the selected category.
280    fn build_mutation(&mut self) {
281        let Some(ref data) = self.data else { return };
282        let key = self.category_key();
283        let Some(section) = data.get(key) else { return };
284
285        match self.selected_category {
286            0 => {
287                self.pending_mutation = Some(PendingMutation::Latency(LatencyConfig {
288                    enabled: section.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
289                    base_ms: section.get("base_ms").and_then(|v| v.as_u64()).unwrap_or(0),
290                    jitter_ms: section.get("jitter_ms").and_then(|v| v.as_u64()).unwrap_or(0),
291                    tag_overrides: HashMap::default(),
292                }));
293            }
294            1 => {
295                self.pending_mutation = Some(PendingMutation::Faults(FaultConfig {
296                    enabled: section.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
297                    failure_rate: section
298                        .get("failure_rate")
299                        .and_then(|v| v.as_f64())
300                        .unwrap_or(0.0),
301                    status_codes: section
302                        .get("status_codes")
303                        .and_then(|v| v.as_array())
304                        .map(|arr| {
305                            arr.iter()
306                                .filter_map(|v| v.as_u64().and_then(|n| u16::try_from(n).ok()))
307                                .collect()
308                        })
309                        .unwrap_or_default(),
310                }));
311            }
312            2 => {
313                self.pending_mutation = Some(PendingMutation::Proxy(ProxyConfig {
314                    enabled: section.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
315                    upstream_url: section
316                        .get("upstream_url")
317                        .and_then(|v| v.as_str())
318                        .map(String::from),
319                    timeout_seconds: section
320                        .get("timeout_seconds")
321                        .and_then(|v| v.as_u64())
322                        .unwrap_or(30),
323                }));
324            }
325            _ => {}
326        }
327    }
328
329    /// Handle key events while the inline text editor is active.
330    fn handle_input_key(&mut self, key: KeyEvent) -> bool {
331        let Some(ref mut buf) = self.input_buf else {
332            return false;
333        };
334        match key.code {
335            KeyCode::Char(c) => {
336                buf.insert(self.input_cursor, c);
337                self.input_cursor += 1;
338            }
339            KeyCode::Backspace => {
340                if self.input_cursor > 0 {
341                    self.input_cursor -= 1;
342                    buf.remove(self.input_cursor);
343                }
344            }
345            KeyCode::Delete => {
346                if self.input_cursor < buf.len() {
347                    buf.remove(self.input_cursor);
348                }
349            }
350            KeyCode::Left => {
351                self.input_cursor = self.input_cursor.saturating_sub(1);
352            }
353            KeyCode::Right => {
354                self.input_cursor = (self.input_cursor + 1).min(buf.len());
355            }
356            KeyCode::Home => {
357                self.input_cursor = 0;
358            }
359            KeyCode::End => {
360                self.input_cursor = buf.len();
361            }
362            KeyCode::Enter => {
363                self.commit_input();
364            }
365            KeyCode::Esc => {
366                self.input_buf = None;
367            }
368            _ => return false,
369        }
370        true
371    }
372}
373
374impl Screen for ConfigScreen {
375    fn title(&self) -> &str {
376        "Config"
377    }
378
379    fn handle_key(&mut self, key: KeyEvent) -> bool {
380        // Inline text editor takes priority.
381        if self.input_buf.is_some() {
382            return self.handle_input_key(key);
383        }
384
385        if self.editing {
386            match key.code {
387                KeyCode::Char('j') | KeyCode::Down => {
388                    if self.selected_field + 1 < self.field_count() {
389                        self.selected_field += 1;
390                    }
391                    return true;
392                }
393                KeyCode::Char('k') | KeyCode::Up => {
394                    self.selected_field = self.selected_field.saturating_sub(1);
395                    return true;
396                }
397                KeyCode::Enter => {
398                    if let Some(field) = self.current_field() {
399                        match field.kind {
400                            FieldKind::Bool => self.toggle_bool(),
401                            FieldKind::Uint | FieldKind::Float | FieldKind::Str => {
402                                self.start_input();
403                            }
404                            FieldKind::ReadOnly => {}
405                        }
406                    }
407                    return true;
408                }
409                KeyCode::Esc => {
410                    self.editing = false;
411                    return true;
412                }
413                KeyCode::Char('r') => {
414                    self.last_fetch = None;
415                    return true;
416                }
417                _ => return false,
418            }
419        }
420
421        match key.code {
422            KeyCode::Char('j') | KeyCode::Down => {
423                if self.selected_category + 1 < CATEGORIES.len() {
424                    self.selected_category += 1;
425                    self.selected_field = 0;
426                }
427                true
428            }
429            KeyCode::Char('k') | KeyCode::Up => {
430                if self.selected_category > 0 {
431                    self.selected_category -= 1;
432                    self.selected_field = 0;
433                }
434                true
435            }
436            KeyCode::Char('e') | KeyCode::Enter => {
437                self.editing = true;
438                self.selected_field = 0;
439                true
440            }
441            KeyCode::Char('r') => {
442                self.last_fetch = None;
443                true
444            }
445            _ => false,
446        }
447    }
448
449    fn render(&self, frame: &mut Frame, area: Rect) {
450        let Some(ref data) = self.data else {
451            let loading = Paragraph::new("Loading config...").style(Theme::dim()).block(
452                Block::default()
453                    .title(" Config ")
454                    .borders(Borders::ALL)
455                    .border_style(Theme::dim()),
456            );
457            frame.render_widget(loading, area);
458            return;
459        };
460
461        let cols = Layout::horizontal([Constraint::Length(22), Constraint::Min(30)]).split(area);
462
463        // Left pane: categories
464        self.render_categories(frame, cols[0]);
465
466        // Right pane: details for selected category
467        self.render_details(frame, cols[1], data);
468    }
469
470    fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
471        // Handle pending mutation.
472        if let Some(mutation) = self.pending_mutation.take() {
473            let client = client.clone();
474            let tx = tx.clone();
475            tokio::spawn(async move {
476                let result = match mutation {
477                    PendingMutation::Latency(config) => {
478                        client.update_latency(&config).await.map(|_| ())
479                    }
480                    PendingMutation::Faults(config) => {
481                        client.update_faults(&config).await.map(|_| ())
482                    }
483                    PendingMutation::Proxy(config) => {
484                        client.update_proxy(&config).await.map(|_| ())
485                    }
486                };
487                match result {
488                    Ok(()) => send_config_data(&client, &tx).await,
489                    Err(e) => {
490                        let _ = tx.send(Event::ApiError {
491                            screen: "config",
492                            message: format!("Update failed: {e}"),
493                        });
494                    }
495                }
496            });
497            return;
498        }
499
500        // On-demand fetch only (first load + manual refresh).
501        if self.last_fetch.is_some() {
502            return;
503        }
504        self.last_fetch = Some(Instant::now());
505
506        let client = client.clone();
507        let tx = tx.clone();
508        tokio::spawn(async move {
509            send_config_data(&client, &tx).await;
510        });
511    }
512
513    fn on_data(&mut self, payload: &str) {
514        match serde_json::from_str::<serde_json::Value>(payload) {
515            Ok(data) => {
516                self.data = Some(data);
517                self.error = None;
518            }
519            Err(e) => {
520                self.error = Some(format!("Parse error: {e}"));
521            }
522        }
523    }
524
525    fn on_error(&mut self, message: &str) {
526        self.error = Some(message.to_string());
527    }
528
529    fn error(&self) -> Option<&str> {
530        self.error.as_deref()
531    }
532
533    fn force_refresh(&mut self) {
534        self.last_fetch = None;
535    }
536
537    fn status_hint(&self) -> &str {
538        if self.input_buf.is_some() {
539            "Enter:save  Esc:cancel  ←→:cursor"
540        } else if self.editing {
541            "Enter:edit field  Esc:stop  j/k:fields  r:refresh"
542        } else {
543            "e:edit  j/k:categories  r:refresh"
544        }
545    }
546}
547
548/// Fetch config from the API and send it as a data event.
549async fn send_config_data(client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
550    match client.get_config().await {
551        Ok(data) => {
552            let payload = config_state_to_json(&data);
553            let _ = tx.send(Event::Data {
554                screen: "config",
555                payload,
556            });
557        }
558        Err(e) => {
559            let _ = tx.send(Event::ApiError {
560                screen: "config",
561                message: e.to_string(),
562            });
563        }
564    }
565}
566
567fn config_state_to_json(data: &ConfigState) -> String {
568    let json = serde_json::json!({
569        "latency": {
570            "enabled": data.latency.enabled,
571            "base_ms": data.latency.base_ms,
572            "jitter_ms": data.latency.jitter_ms,
573        },
574        "faults": {
575            "enabled": data.faults.enabled,
576            "failure_rate": data.faults.failure_rate,
577            "status_codes": data.faults.status_codes,
578        },
579        "proxy": {
580            "enabled": data.proxy.enabled,
581            "upstream_url": data.proxy.upstream_url,
582            "timeout_seconds": data.proxy.timeout_seconds,
583        },
584        "traffic_shaping": {
585            "enabled": data.traffic_shaping.enabled,
586            "bandwidth": data.traffic_shaping.bandwidth,
587            "burst_loss": data.traffic_shaping.burst_loss,
588        },
589        "validation": {
590            "mode": data.validation.mode,
591            "aggregate_errors": data.validation.aggregate_errors,
592            "validate_responses": data.validation.validate_responses,
593        },
594    });
595    serde_json::to_string(&json).unwrap_or_default()
596}
597
598impl ConfigScreen {
599    fn render_categories(&self, frame: &mut Frame, area: Rect) {
600        let block = Block::default()
601            .title(" Categories ")
602            .title_style(Theme::title())
603            .borders(Borders::ALL)
604            .border_style(if self.editing {
605                Theme::dim()
606            } else {
607                Style::default().fg(Theme::BLUE)
608            })
609            .style(Theme::surface());
610
611        let lines: Vec<Line> = CATEGORIES
612            .iter()
613            .enumerate()
614            .map(|(i, &name)| {
615                let style = if i == self.selected_category {
616                    if self.editing {
617                        Style::default().fg(Theme::BLUE).add_modifier(Modifier::BOLD)
618                    } else {
619                        Style::default().fg(Theme::BG).bg(Theme::BLUE).add_modifier(Modifier::BOLD)
620                    }
621                } else {
622                    Style::default().fg(Theme::FG)
623                };
624                Line::from(Span::styled(format!(" {name}"), style))
625            })
626            .collect();
627
628        let paragraph = Paragraph::new(lines).block(block);
629        frame.render_widget(paragraph, area);
630    }
631
632    fn render_details(&self, frame: &mut Frame, area: Rect, data: &serde_json::Value) {
633        let cat_key = self.category_key();
634        let fields = fields_for_category(self.selected_category);
635        let editing_indicator = if self.editing { " [EDITING]" } else { "" };
636
637        let border_style = if self.editing {
638            Style::default().fg(Theme::BLUE)
639        } else {
640            Theme::dim()
641        };
642
643        let block = Block::default()
644            .title(format!(" {} {editing_indicator}", CATEGORIES[self.selected_category]))
645            .title_style(Theme::title())
646            .borders(Borders::ALL)
647            .border_style(border_style)
648            .style(Theme::surface());
649
650        let section = data.get(cat_key);
651        let mut lines = Vec::new();
652
653        for (i, field) in fields.iter().enumerate() {
654            let value = section.and_then(|s| s.get(field.json_key));
655            let is_selected = self.editing && i == self.selected_field;
656            let is_readonly = matches!(field.kind, FieldKind::ReadOnly);
657
658            // Field label
659            let label_style = if is_selected {
660                Style::default().fg(Theme::BLUE).add_modifier(Modifier::BOLD)
661            } else {
662                Theme::dim()
663            };
664
665            let kind_hint = match field.kind {
666                FieldKind::Bool => "",
667                FieldKind::ReadOnly => " (ro)",
668                _ => "",
669            };
670
671            let selector = if is_selected { "▸ " } else { "  " };
672
673            // Value rendering
674            let value_span = if is_selected && self.input_buf.is_some() {
675                // Inline text editor is active.
676                let buf = self.input_buf.as_deref().unwrap_or("");
677                Span::styled(format!("{buf}▏"), Style::default().fg(Theme::FG).bg(Theme::OVERLAY))
678            } else {
679                let display = format_field_value(value, field.kind);
680                let style = if is_readonly {
681                    Theme::dim()
682                } else if is_selected {
683                    Style::default().fg(Theme::BLUE).add_modifier(Modifier::BOLD)
684                } else {
685                    Style::default().fg(Theme::FG)
686                };
687                Span::styled(display, style)
688            };
689
690            lines.push(Line::from(vec![
691                Span::styled(selector.to_string(), label_style),
692                Span::styled(format!("{:<20}{kind_hint}", field.name), label_style),
693                value_span,
694            ]));
695        }
696
697        if fields.is_empty() {
698            lines.push(Line::from(Span::styled(" No data for this category", Theme::dim())));
699        }
700
701        let paragraph = Paragraph::new(lines).block(block);
702        frame.render_widget(paragraph, area);
703    }
704}
705
706fn format_field_value(value: Option<&serde_json::Value>, kind: FieldKind) -> String {
707    match value {
708        Some(serde_json::Value::Bool(b)) => {
709            if *b {
710                "true".to_string()
711            } else {
712                "false".to_string()
713            }
714        }
715        Some(serde_json::Value::Number(n)) => {
716            if let Some(f) = n.as_f64() {
717                if matches!(kind, FieldKind::Float) {
718                    format!("{f:.2}")
719                } else {
720                    n.to_string()
721                }
722            } else {
723                n.to_string()
724            }
725        }
726        Some(serde_json::Value::String(s)) => s.clone(),
727        Some(serde_json::Value::Null) => "—".to_string(),
728        Some(serde_json::Value::Array(arr)) => {
729            let items: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
730            format!("[{}]", items.join(", "))
731        }
732        Some(v) => v.to_string(),
733        None => "—".to_string(),
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
741
742    fn key(code: KeyCode) -> KeyEvent {
743        KeyEvent {
744            code,
745            modifiers: KeyModifiers::NONE,
746            kind: KeyEventKind::Press,
747            state: KeyEventState::NONE,
748        }
749    }
750
751    #[test]
752    fn new_starts_at_first_category() {
753        let s = ConfigScreen::new();
754        assert_eq!(s.selected_category, 0);
755        assert_eq!(s.selected_field, 0);
756        assert!(!s.editing);
757        assert!(s.input_buf.is_none());
758    }
759
760    #[test]
761    fn category_navigation() {
762        let mut s = ConfigScreen::new();
763        s.data = Some(serde_json::json!({}));
764
765        s.handle_key(key(KeyCode::Char('j')));
766        assert_eq!(s.selected_category, 1);
767
768        s.handle_key(key(KeyCode::Char('j')));
769        assert_eq!(s.selected_category, 2);
770
771        s.handle_key(key(KeyCode::Char('k')));
772        assert_eq!(s.selected_category, 1);
773
774        // Can't go below 0
775        s.handle_key(key(KeyCode::Char('k')));
776        s.handle_key(key(KeyCode::Char('k')));
777        assert_eq!(s.selected_category, 0);
778    }
779
780    #[test]
781    fn enter_edit_mode() {
782        let mut s = ConfigScreen::new();
783
784        s.handle_key(key(KeyCode::Char('e')));
785        assert!(s.editing);
786        assert_eq!(s.selected_field, 0);
787
788        s.handle_key(key(KeyCode::Esc));
789        assert!(!s.editing);
790    }
791
792    #[test]
793    fn field_navigation_in_edit_mode() {
794        let mut s = ConfigScreen::new();
795        s.data = Some(serde_json::json!({
796            "latency": { "enabled": true, "base_ms": 100, "jitter_ms": 50 }
797        }));
798
799        s.handle_key(key(KeyCode::Char('e'))); // enter edit mode
800        assert_eq!(s.selected_field, 0);
801
802        s.handle_key(key(KeyCode::Char('j')));
803        assert_eq!(s.selected_field, 1);
804
805        s.handle_key(key(KeyCode::Char('j')));
806        assert_eq!(s.selected_field, 2);
807
808        // Can't go past last field
809        s.handle_key(key(KeyCode::Char('j')));
810        assert_eq!(s.selected_field, 2);
811
812        s.handle_key(key(KeyCode::Char('k')));
813        assert_eq!(s.selected_field, 1);
814    }
815
816    #[test]
817    fn toggle_bool_field() {
818        let mut s = ConfigScreen::new();
819        s.data = Some(serde_json::json!({
820            "latency": { "enabled": false, "base_ms": 100, "jitter_ms": 50 }
821        }));
822
823        s.handle_key(key(KeyCode::Char('e'))); // enter edit mode
824                                               // Field 0 is "enabled" (Bool)
825        s.handle_key(key(KeyCode::Enter));
826
827        // Should have toggled and created a mutation.
828        let enabled = s.data.as_ref().unwrap()["latency"]["enabled"].as_bool().unwrap();
829        assert!(enabled);
830        assert!(s.pending_mutation.is_some());
831    }
832
833    #[test]
834    fn inline_edit_numeric_field() {
835        let mut s = ConfigScreen::new();
836        s.data = Some(serde_json::json!({
837            "latency": { "enabled": true, "base_ms": 100, "jitter_ms": 50 }
838        }));
839
840        s.handle_key(key(KeyCode::Char('e'))); // enter edit mode
841        s.handle_key(key(KeyCode::Char('j'))); // move to base_ms field
842        s.handle_key(key(KeyCode::Enter)); // start inline edit
843
844        assert!(s.input_buf.is_some());
845        assert_eq!(s.input_buf.as_deref(), Some("100"));
846
847        // Clear and type new value.
848        s.handle_input_key(key(KeyCode::Home));
849        // Select all by moving to end with delete
850        for _ in 0..3 {
851            s.handle_input_key(key(KeyCode::Delete));
852        }
853        s.handle_input_key(key(KeyCode::Char('2')));
854        s.handle_input_key(key(KeyCode::Char('5')));
855        s.handle_input_key(key(KeyCode::Char('0')));
856
857        assert_eq!(s.input_buf.as_deref(), Some("250"));
858
859        // Commit
860        s.handle_input_key(key(KeyCode::Enter));
861        assert!(s.input_buf.is_none());
862
863        let base_ms = s.data.as_ref().unwrap()["latency"]["base_ms"].as_u64().unwrap();
864        assert_eq!(base_ms, 250);
865        assert!(s.pending_mutation.is_some());
866    }
867
868    #[test]
869    fn inline_edit_cancel_with_esc() {
870        let mut s = ConfigScreen::new();
871        s.data = Some(serde_json::json!({
872            "latency": { "enabled": true, "base_ms": 100, "jitter_ms": 50 }
873        }));
874
875        s.handle_key(key(KeyCode::Char('e')));
876        s.handle_key(key(KeyCode::Char('j'))); // base_ms
877        s.handle_key(key(KeyCode::Enter)); // start edit
878
879        assert!(s.input_buf.is_some());
880
881        // Type something then cancel.
882        s.handle_input_key(key(KeyCode::Char('9')));
883        s.handle_input_key(key(KeyCode::Esc));
884
885        assert!(s.input_buf.is_none());
886
887        // Original value should be unchanged.
888        let base_ms = s.data.as_ref().unwrap()["latency"]["base_ms"].as_u64().unwrap();
889        assert_eq!(base_ms, 100);
890        assert!(s.pending_mutation.is_none());
891    }
892
893    #[test]
894    fn readonly_fields_dont_edit() {
895        let mut s = ConfigScreen::new();
896        s.data = Some(serde_json::json!({
897            "traffic_shaping": { "enabled": false, "bandwidth": null, "burst_loss": null }
898        }));
899        s.selected_category = 3; // Traffic Shaping (all readonly)
900
901        s.handle_key(key(KeyCode::Char('e')));
902        s.handle_key(key(KeyCode::Enter));
903
904        // Should not start inline edit or toggle.
905        assert!(s.input_buf.is_none());
906        assert!(s.pending_mutation.is_none());
907    }
908
909    #[test]
910    fn category_change_resets_field() {
911        let mut s = ConfigScreen::new();
912        s.selected_field = 2;
913
914        s.handle_key(key(KeyCode::Char('j'))); // change category
915        assert_eq!(s.selected_field, 0);
916    }
917
918    #[test]
919    fn fields_for_each_category() {
920        assert_eq!(fields_for_category(0).len(), 3); // Latency
921        assert_eq!(fields_for_category(1).len(), 3); // Faults
922        assert_eq!(fields_for_category(2).len(), 3); // Proxy
923        assert_eq!(fields_for_category(3).len(), 3); // Traffic Shaping
924        assert_eq!(fields_for_category(4).len(), 3); // Validation
925    }
926
927    #[test]
928    fn format_field_value_formats_correctly() {
929        let bool_val = serde_json::json!(true);
930        assert_eq!(format_field_value(Some(&bool_val), FieldKind::Bool), "true");
931
932        let int_val = serde_json::json!(42);
933        assert_eq!(format_field_value(Some(&int_val), FieldKind::Uint), "42");
934
935        let float_val = serde_json::json!(0.15);
936        assert_eq!(format_field_value(Some(&float_val), FieldKind::Float), "0.15");
937
938        let str_val = serde_json::json!("http://example.com");
939        assert_eq!(format_field_value(Some(&str_val), FieldKind::Str), "http://example.com");
940
941        assert_eq!(format_field_value(None, FieldKind::Uint), "—");
942
943        let null_val = serde_json::json!(null);
944        assert_eq!(format_field_value(Some(&null_val), FieldKind::Str), "—");
945
946        let arr_val = serde_json::json!([500, 503]);
947        assert_eq!(format_field_value(Some(&arr_val), FieldKind::ReadOnly), "[500, 503]");
948    }
949
950    #[test]
951    fn status_hints_change_with_mode() {
952        let mut s = ConfigScreen::new();
953
954        assert!(s.status_hint().contains("e:edit"));
955
956        s.editing = true;
957        assert!(s.status_hint().contains("Enter:edit field"));
958
959        s.input_buf = Some("123".to_string());
960        assert!(s.status_hint().contains("Enter:save"));
961    }
962
963    #[test]
964    fn edit_float_field_faults() {
965        let mut s = ConfigScreen::new();
966        s.data = Some(serde_json::json!({
967            "faults": { "enabled": true, "failure_rate": 0.1, "status_codes": [500] }
968        }));
969        s.selected_category = 1; // Faults
970
971        s.handle_key(key(KeyCode::Char('e')));
972        s.handle_key(key(KeyCode::Char('j'))); // failure_rate
973        s.handle_key(key(KeyCode::Enter)); // start edit
974
975        assert!(s.input_buf.is_some());
976
977        // Clear and type new value.
978        s.handle_input_key(key(KeyCode::Home));
979        for _ in 0..10 {
980            s.handle_input_key(key(KeyCode::Delete));
981        }
982        s.handle_input_key(key(KeyCode::Char('0')));
983        s.handle_input_key(key(KeyCode::Char('.')));
984        s.handle_input_key(key(KeyCode::Char('5')));
985        s.handle_input_key(key(KeyCode::Enter));
986
987        let rate = s.data.as_ref().unwrap()["faults"]["failure_rate"].as_f64().unwrap();
988        assert!((rate - 0.5).abs() < f64::EPSILON);
989    }
990
991    #[test]
992    fn edit_string_field_proxy() {
993        let mut s = ConfigScreen::new();
994        s.data = Some(serde_json::json!({
995            "proxy": { "enabled": false, "upstream_url": "http://old.com", "timeout_seconds": 30 }
996        }));
997        s.selected_category = 2; // Proxy
998
999        s.handle_key(key(KeyCode::Char('e')));
1000        s.handle_key(key(KeyCode::Char('j'))); // upstream_url
1001        s.handle_key(key(KeyCode::Enter)); // start edit
1002
1003        assert_eq!(s.input_buf.as_deref(), Some("http://old.com"));
1004
1005        // Clear and type new URL.
1006        s.handle_input_key(key(KeyCode::Home));
1007        for _ in 0..20 {
1008            s.handle_input_key(key(KeyCode::Delete));
1009        }
1010        for c in "http://new.com".chars() {
1011            s.handle_input_key(key(KeyCode::Char(c)));
1012        }
1013        s.handle_input_key(key(KeyCode::Enter));
1014
1015        let url = s.data.as_ref().unwrap()["proxy"]["upstream_url"].as_str().unwrap();
1016        assert_eq!(url, "http://new.com");
1017    }
1018}