1use 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#[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
47fn 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 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 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 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 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 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 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 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 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 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 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 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 self.render_categories(frame, cols[0]);
465
466 self.render_details(frame, cols[1], data);
468 }
469
470 fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
471 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 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
548async 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 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 let value_span = if is_selected && self.input_buf.is_some() {
675 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 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'))); 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 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'))); s.handle_key(key(KeyCode::Enter));
826
827 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'))); s.handle_key(key(KeyCode::Char('j'))); s.handle_key(key(KeyCode::Enter)); assert!(s.input_buf.is_some());
845 assert_eq!(s.input_buf.as_deref(), Some("100"));
846
847 s.handle_input_key(key(KeyCode::Home));
849 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 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'))); s.handle_key(key(KeyCode::Enter)); assert!(s.input_buf.is_some());
880
881 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 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; s.handle_key(key(KeyCode::Char('e')));
902 s.handle_key(key(KeyCode::Enter));
903
904 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'))); 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); assert_eq!(fields_for_category(1).len(), 3); assert_eq!(fields_for_category(2).len(), 3); assert_eq!(fields_for_category(3).len(), 3); assert_eq!(fields_for_category(4).len(), 3); }
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; s.handle_key(key(KeyCode::Char('e')));
972 s.handle_key(key(KeyCode::Char('j'))); s.handle_key(key(KeyCode::Enter)); assert!(s.input_buf.is_some());
976
977 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; s.handle_key(key(KeyCode::Char('e')));
1000 s.handle_key(key(KeyCode::Char('j'))); s.handle_key(key(KeyCode::Enter)); assert_eq!(s.input_buf.as_deref(), Some("http://old.com"));
1004
1005 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}