1use std::collections::VecDeque;
19
20use color_eyre::Result;
21use ratatui::{
22 Frame,
23 layout::{Constraint, Layout, Rect},
24 style::{Color, Modifier, Style},
25 text::{Line, Span},
26 widgets::{Block, Borders, Paragraph},
27};
28
29use super::Component;
30use crate::action::Action;
31use crate::log_capture::{CockpitCapture, CockpitEntry, LogCapture, LogEntry};
32use crate::state::{LOG_PANE_MAX_HEIGHT, LOG_PANE_MIN_HEIGHT};
33use crate::theme;
34
35const BEE_TAB_RING_CAPACITY: usize = 500;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum LogTab {
44 Errors,
45 Warning,
46 Info,
47 Debug,
48 BeeHttp,
49 SelfHttp,
50 Cockpit,
54}
55
56impl LogTab {
57 pub const ALL: [LogTab; 7] = [
58 LogTab::Errors,
59 LogTab::Warning,
60 LogTab::Info,
61 LogTab::Debug,
62 LogTab::BeeHttp,
63 LogTab::SelfHttp,
64 LogTab::Cockpit,
65 ];
66
67 pub fn from_kebab(s: &str) -> Self {
71 match s {
72 "errors" => Self::Errors,
73 "warning" => Self::Warning,
74 "info" => Self::Info,
75 "debug" => Self::Debug,
76 "bee-http" => Self::BeeHttp,
77 "self-http" => Self::SelfHttp,
78 "cockpit" => Self::Cockpit,
79 _ => Self::SelfHttp,
80 }
81 }
82
83 pub fn to_kebab(self) -> &'static str {
84 match self {
85 Self::Errors => "errors",
86 Self::Warning => "warning",
87 Self::Info => "info",
88 Self::Debug => "debug",
89 Self::BeeHttp => "bee-http",
90 Self::SelfHttp => "self-http",
91 Self::Cockpit => "cockpit",
92 }
93 }
94
95 pub fn label(self) -> &'static str {
97 match self {
98 Self::Errors => "Errors",
99 Self::Warning => "Warn",
100 Self::Info => "Info",
101 Self::Debug => "Debug",
102 Self::BeeHttp => "Bee HTTP",
103 Self::SelfHttp => "bee::http",
104 Self::Cockpit => "Cockpit",
105 }
106 }
107
108 fn index(self) -> usize {
110 Self::ALL.iter().position(|t| *t == self).unwrap_or(5)
111 }
112
113 fn from_index(i: usize) -> Self {
114 Self::ALL.get(i).copied().unwrap_or(Self::SelfHttp)
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct BeeLogLine {
124 pub timestamp: String,
126 pub logger: String,
128 pub message: String,
130}
131
132pub enum LogRow<'a> {
135 Self_(&'a LogEntry),
136 Bee(&'a BeeLogLine),
137}
138
139#[derive(Debug, Default)]
142pub struct BeeLogBuffers {
143 pub errors: VecDeque<BeeLogLine>,
144 pub warning: VecDeque<BeeLogLine>,
145 pub info: VecDeque<BeeLogLine>,
146 pub debug: VecDeque<BeeLogLine>,
147 pub bee_http: VecDeque<BeeLogLine>,
148}
149
150impl BeeLogBuffers {
151 fn buffer_for(&self, tab: LogTab) -> Option<&VecDeque<BeeLogLine>> {
152 match tab {
153 LogTab::Errors => Some(&self.errors),
154 LogTab::Warning => Some(&self.warning),
155 LogTab::Info => Some(&self.info),
156 LogTab::Debug => Some(&self.debug),
157 LogTab::BeeHttp => Some(&self.bee_http),
158 LogTab::SelfHttp | LogTab::Cockpit => None,
159 }
160 }
161
162 pub fn count(&self, tab: LogTab) -> usize {
165 self.buffer_for(tab).map(|b| b.len()).unwrap_or(0)
166 }
167}
168
169pub struct LogPane {
173 capture: Option<LogCapture>,
174 self_http_entries: Vec<LogEntry>,
175 cockpit_capture: Option<CockpitCapture>,
176 cockpit_entries: Vec<CockpitEntry>,
177 bee_buffers: BeeLogBuffers,
178 active_tab: LogTab,
179 height: u16,
181 spawn_active: bool,
185 scroll_offset: usize,
190 h_scroll_offset: u16,
194}
195
196impl LogPane {
197 pub fn new(capture: Option<LogCapture>, initial_tab: LogTab, initial_height: u16) -> Self {
198 Self {
199 capture,
200 self_http_entries: Vec::new(),
201 cockpit_capture: None,
202 cockpit_entries: Vec::new(),
203 bee_buffers: BeeLogBuffers::default(),
204 active_tab: initial_tab,
205 height: initial_height.clamp(LOG_PANE_MIN_HEIGHT, LOG_PANE_MAX_HEIGHT),
206 spawn_active: false,
207 scroll_offset: 0,
208 h_scroll_offset: 0,
209 }
210 }
211
212 pub fn set_cockpit_capture(&mut self, cap: CockpitCapture) {
217 self.cockpit_capture = Some(cap);
218 }
219
220 pub fn active_tab(&self) -> LogTab {
221 self.active_tab
222 }
223
224 pub fn height(&self) -> u16 {
225 self.height
226 }
227
228 pub fn set_spawn_active(&mut self, active: bool) {
232 self.spawn_active = active;
233 }
234
235 pub fn next_tab(&mut self) -> LogTab {
240 let i = (self.active_tab.index() + 1) % LogTab::ALL.len();
241 self.active_tab = LogTab::from_index(i);
242 self.scroll_offset = 0;
243 self.h_scroll_offset = 0;
244 self.active_tab
245 }
246
247 pub fn prev_tab(&mut self) -> LogTab {
249 let len = LogTab::ALL.len();
250 let i = (self.active_tab.index() + len - 1) % len;
251 self.active_tab = LogTab::from_index(i);
252 self.scroll_offset = 0;
253 self.h_scroll_offset = 0;
254 self.active_tab
255 }
256
257 pub fn scroll_up(&mut self, lines: usize) {
262 self.scroll_offset = self.scroll_offset.saturating_add(lines);
263 }
264
265 pub fn scroll_down(&mut self, lines: usize) {
268 self.scroll_offset = self.scroll_offset.saturating_sub(lines);
269 }
270
271 pub fn resume_tail(&mut self) {
276 self.scroll_offset = 0;
277 self.h_scroll_offset = 0;
278 }
279
280 pub fn scroll_right(&mut self, cols: u16) {
285 self.h_scroll_offset = self.h_scroll_offset.saturating_add(cols);
286 }
287
288 pub fn scroll_left(&mut self, cols: u16) {
291 self.h_scroll_offset = self.h_scroll_offset.saturating_sub(cols);
292 }
293
294 pub fn reset_h_scroll(&mut self) {
298 self.h_scroll_offset = 0;
299 }
300
301 pub fn h_scroll_offset(&self) -> u16 {
304 self.h_scroll_offset
305 }
306
307 pub fn is_tailing(&self) -> bool {
309 self.scroll_offset == 0
310 }
311
312 pub fn scroll_offset(&self) -> usize {
315 self.scroll_offset
316 }
317
318 pub fn grow(&mut self) -> u16 {
321 self.height = (self.height + 1).min(LOG_PANE_MAX_HEIGHT);
322 self.height
323 }
324
325 pub fn shrink(&mut self) -> u16 {
328 self.height = self.height.saturating_sub(1).max(LOG_PANE_MIN_HEIGHT);
329 self.height
330 }
331
332 pub fn push_bee(&mut self, tab: LogTab, line: BeeLogLine) {
342 let buf = match tab {
343 LogTab::Errors => &mut self.bee_buffers.errors,
344 LogTab::Warning => &mut self.bee_buffers.warning,
345 LogTab::Info => &mut self.bee_buffers.info,
346 LogTab::Debug => &mut self.bee_buffers.debug,
347 LogTab::BeeHttp => &mut self.bee_buffers.bee_http,
348 LogTab::SelfHttp | LogTab::Cockpit => return, };
350 let was_full = buf.len() == BEE_TAB_RING_CAPACITY;
351 if was_full {
352 buf.pop_front();
353 }
354 buf.push_back(line);
355 if tab == self.active_tab && self.scroll_offset > 0 && !was_full {
360 self.scroll_offset = self.scroll_offset.saturating_add(1);
361 }
362 }
363
364 fn pull_self_http(&mut self) {
365 if let Some(c) = &self.capture {
366 let new = c.snapshot();
367 if self.active_tab == LogTab::SelfHttp && self.scroll_offset > 0 {
372 let delta = new.len().saturating_sub(self.self_http_entries.len());
373 if delta > 0 {
374 self.scroll_offset = self.scroll_offset.saturating_add(delta);
375 }
376 }
377 self.self_http_entries = new;
378 }
379 }
380
381 fn pull_cockpit(&mut self) {
382 if let Some(c) = &self.cockpit_capture {
383 let new = c.snapshot();
384 if self.active_tab == LogTab::Cockpit && self.scroll_offset > 0 {
385 let delta = new.len().saturating_sub(self.cockpit_entries.len());
386 if delta > 0 {
387 self.scroll_offset = self.scroll_offset.saturating_add(delta);
388 }
389 }
390 self.cockpit_entries = new;
391 }
392 }
393}
394
395impl Component for LogPane {
396 fn update(&mut self, action: Action) -> Result<Option<Action>> {
397 if matches!(action, Action::Tick) {
398 self.pull_self_http();
399 self.pull_cockpit();
400 }
401 Ok(None)
402 }
403
404 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
405 let t = theme::active();
406 let active = self.active_tab;
407
408 let content_h = (area.height as usize).saturating_sub(2);
412 let total_lines = self.active_tab_total_lines();
413 let max_offset = total_lines.saturating_sub(content_h);
414 if self.scroll_offset > max_offset {
415 self.scroll_offset = max_offset;
416 }
417
418 let block = Block::default().borders(Borders::ALL).title(tab_title_line(
419 active,
420 &self.bee_buffers,
421 self.scroll_offset,
422 self.h_scroll_offset,
423 t,
424 ));
425 let inner = block.inner(area);
426 frame.render_widget(block, area);
427
428 let content_area = Layout::vertical([Constraint::Min(0)])
429 .split(inner)
430 .first()
431 .copied()
432 .unwrap_or(inner);
433
434 let lines: Vec<Line> = match active {
435 LogTab::SelfHttp => render_self_http(&self.self_http_entries, t),
436 LogTab::Cockpit => render_cockpit(&self.cockpit_entries, t),
437 tab => render_bee_tab(&self.bee_buffers, tab, self.spawn_active, t),
438 };
439
440 let render_h = content_area.height as usize;
445 let visible: Vec<Line> = if lines.len() > render_h {
446 let end = lines.len().saturating_sub(self.scroll_offset);
447 let start = end.saturating_sub(render_h);
448 lines.into_iter().skip(start).take(end - start).collect()
449 } else {
450 lines
451 };
452
453 frame.render_widget(
460 Paragraph::new(visible).scroll((0, self.h_scroll_offset)),
461 content_area,
462 );
463 Ok(())
464 }
465}
466
467impl LogPane {
468 pub fn active_tab_total_lines(&self) -> usize {
471 match self.active_tab {
472 LogTab::SelfHttp => self.self_http_entries.len(),
473 LogTab::Cockpit => self.cockpit_entries.len(),
474 tab => self.bee_buffers.count(tab),
475 }
476 }
477}
478
479fn tab_title_line<'a>(
484 active: LogTab,
485 bufs: &BeeLogBuffers,
486 scroll_offset: usize,
487 h_scroll_offset: u16,
488 t: &theme::Theme,
489) -> Line<'a> {
490 let mut spans: Vec<Span> = Vec::with_capacity(LogTab::ALL.len() * 2 + 2);
491 spans.push(Span::raw(" "));
492 for tab in LogTab::ALL {
493 let count = bufs.count(tab);
494 let label = if count == 0 || matches!(tab, LogTab::SelfHttp | LogTab::Cockpit) {
498 format!(" {} ", tab.label())
499 } else {
500 format!(" {} {} ", tab.label(), human_count(count))
501 };
502 let style = if tab == active {
503 Style::default()
504 .fg(t.tab_active_fg)
505 .bg(t.tab_active_bg)
506 .add_modifier(Modifier::BOLD)
507 } else {
508 tab_severity_color(tab, t)
509 };
510 spans.push(Span::styled(label, style));
511 spans.push(Span::raw(" "));
512 }
513 if scroll_offset > 0 {
517 spans.push(Span::styled(
518 format!(" paused {scroll_offset} ↑ "),
519 Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
520 ));
521 }
522 if h_scroll_offset > 0 {
523 spans.push(Span::styled(
527 format!(" → {h_scroll_offset} "),
528 Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
529 ));
530 }
531 Line::from(spans)
532}
533
534fn tab_severity_color(tab: LogTab, t: &theme::Theme) -> Style {
537 match tab {
538 LogTab::Errors => Style::default().fg(t.fail),
539 LogTab::Warning => Style::default().fg(t.warn),
540 LogTab::Info => Style::default().fg(t.info),
541 LogTab::Debug => Style::default().fg(t.dim),
542 LogTab::BeeHttp => Style::default().fg(t.accent),
543 LogTab::SelfHttp => Style::default().fg(t.dim),
544 LogTab::Cockpit => Style::default().fg(t.accent),
545 }
546}
547
548fn human_count(n: usize) -> String {
549 if n < 1_000 {
550 n.to_string()
551 } else if n < 1_000_000 {
552 format!("{:.1}k", n as f64 / 1000.0)
553 } else {
554 format!("{:.1}m", n as f64 / 1_000_000.0)
555 }
556}
557
558fn render_cockpit<'a>(entries: &'a [CockpitEntry], t: &theme::Theme) -> Vec<Line<'a>> {
559 if entries.is_empty() {
560 return vec![Line::from(Span::styled(
561 " (no cockpit-internal events captured yet)",
562 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
563 ))];
564 }
565 entries.iter().map(|e| cockpit_line(e, t)).collect()
566}
567
568fn cockpit_line<'a>(e: &'a CockpitEntry, t: &theme::Theme) -> Line<'a> {
569 let level_style = match e.level.as_str() {
570 "ERROR" => Style::default().fg(t.fail).add_modifier(Modifier::BOLD),
571 "WARN" => Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
572 "INFO" => Style::default().fg(t.info),
573 _ => Style::default().fg(t.dim),
574 };
575 Line::from(vec![
576 Span::styled(format!("{} ", e.ts), Style::default().fg(t.dim)),
577 Span::styled(format!("{:<5}", e.level), level_style),
578 Span::raw(" "),
579 Span::styled(
580 format!("{:<22}", trim_target(&e.target)),
581 Style::default().fg(t.accent),
582 ),
583 Span::raw(" "),
584 Span::raw(e.message.clone()),
585 ])
586}
587
588fn trim_target(target: &str) -> &str {
592 target.strip_prefix("bee_tui::").unwrap_or(target)
593}
594
595fn render_self_http<'a>(entries: &'a [LogEntry], t: &theme::Theme) -> Vec<Line<'a>> {
596 if entries.is_empty() {
597 return vec![Line::from(Span::styled(
598 " (waiting for first request…)",
599 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
600 ))];
601 }
602 entries.iter().map(|e| self_http_line(e, t)).collect()
603}
604
605fn render_bee_tab<'a>(
606 bufs: &'a BeeLogBuffers,
607 tab: LogTab,
608 spawn_active: bool,
609 t: &theme::Theme,
610) -> Vec<Line<'a>> {
611 let buf = match bufs.buffer_for(tab) {
612 Some(b) => b,
613 None => return Vec::new(),
614 };
615 if buf.is_empty() {
616 let msg = if spawn_active {
617 " (awaiting bee log entries on this severity…)"
618 } else {
619 " (no bee child — set [bee] in config or pass --bee-bin / --bee-config)"
620 };
621 return vec![Line::from(Span::styled(
622 msg,
623 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
624 ))];
625 }
626 buf.iter()
627 .map(|line| {
628 Line::from(vec![
629 Span::styled(format!("{} ", line.timestamp), Style::default().fg(t.dim)),
630 Span::styled(
631 format!("{:<22}", line.logger),
632 Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
633 ),
634 Span::raw(" "),
635 Span::raw(line.message.clone()),
636 ])
637 })
638 .collect()
639}
640
641fn self_http_line<'a>(e: &'a LogEntry, t: &theme::Theme) -> Line<'a> {
642 let status_style = match e.status {
643 Some(s) if (200..300).contains(&s) => Style::default().fg(t.pass),
644 Some(s) if (300..400).contains(&s) => Style::default().fg(t.info),
645 Some(s) if (400..500).contains(&s) => Style::default().fg(t.warn),
646 Some(_) => Style::default().fg(t.fail),
647 None => Style::default().fg(t.dim),
648 };
649 let method_style = Style::default()
650 .fg(method_color(&e.method))
651 .add_modifier(Modifier::BOLD);
652 let elapsed = e
653 .elapsed_ms
654 .map(|ms| format!("{ms:>4}ms"))
655 .unwrap_or_else(|| " —".into());
656 let path = path_only(&e.url);
657 Line::from(vec![
658 Span::styled(format!("{} ", e.ts), Style::default().fg(t.dim)),
659 Span::styled(format!("{:<5}", e.method), method_style),
660 Span::raw(" "),
661 Span::raw(path),
662 Span::raw(" "),
663 Span::styled(
664 e.status
665 .map(|s| s.to_string())
666 .unwrap_or_else(|| "—".into()),
667 status_style,
668 ),
669 Span::raw(" "),
670 Span::styled(elapsed, Style::default().fg(t.dim)),
671 ])
672}
673
674fn method_color(method: &str) -> Color {
678 match method {
679 "GET" => Color::Blue,
680 "POST" => Color::Green,
681 "PUT" => Color::Yellow,
682 "DELETE" => Color::Red,
683 "PATCH" => Color::Magenta,
684 "HEAD" => Color::Cyan,
685 _ => Color::White,
686 }
687}
688
689fn path_only(url: &str) -> String {
693 if let Some(rest) = url.split_once("//").and_then(|(_, r)| r.split_once('/')) {
694 format!("/{}", rest.1)
695 } else {
696 url.to_string()
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn next_tab_wraps() {
706 let mut pane = LogPane::new(None, LogTab::Errors, 10);
707 for expected in [
708 LogTab::Warning,
709 LogTab::Info,
710 LogTab::Debug,
711 LogTab::BeeHttp,
712 LogTab::SelfHttp,
713 LogTab::Cockpit,
714 LogTab::Errors,
715 ] {
716 assert_eq!(pane.next_tab(), expected);
717 }
718 }
719
720 #[test]
721 fn prev_tab_wraps() {
722 let mut pane = LogPane::new(None, LogTab::Errors, 10);
723 for expected in [
724 LogTab::Cockpit,
725 LogTab::SelfHttp,
726 LogTab::BeeHttp,
727 LogTab::Debug,
728 LogTab::Info,
729 LogTab::Warning,
730 LogTab::Errors,
731 ] {
732 assert_eq!(pane.prev_tab(), expected);
733 }
734 }
735
736 #[test]
737 fn grow_clamps_at_max() {
738 let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MAX_HEIGHT - 1);
739 pane.grow();
740 assert_eq!(pane.height(), LOG_PANE_MAX_HEIGHT);
741 pane.grow();
743 pane.grow();
744 assert_eq!(pane.height(), LOG_PANE_MAX_HEIGHT);
745 }
746
747 #[test]
748 fn shrink_clamps_at_min() {
749 let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT + 1);
750 pane.shrink();
751 assert_eq!(pane.height(), LOG_PANE_MIN_HEIGHT);
752 pane.shrink();
753 pane.shrink();
754 assert_eq!(pane.height(), LOG_PANE_MIN_HEIGHT);
755 }
756
757 #[test]
758 fn fresh_pane_is_tailing() {
759 let pane = LogPane::new(None, LogTab::Errors, 10);
760 assert!(pane.is_tailing());
761 assert_eq!(pane.scroll_offset(), 0);
762 }
763
764 #[test]
765 fn scroll_up_disables_tail_and_remembers_offset() {
766 let mut pane = LogPane::new(None, LogTab::Errors, 10);
767 pane.scroll_up(3);
768 assert!(!pane.is_tailing());
769 assert_eq!(pane.scroll_offset(), 3);
770 pane.scroll_up(2);
771 assert_eq!(pane.scroll_offset(), 5);
772 }
773
774 #[test]
775 fn scroll_down_eventually_resumes_tail() {
776 let mut pane = LogPane::new(None, LogTab::Errors, 10);
777 pane.scroll_up(5);
778 pane.scroll_down(2);
779 assert_eq!(pane.scroll_offset(), 3);
780 pane.scroll_down(100);
782 assert_eq!(pane.scroll_offset(), 0);
783 assert!(pane.is_tailing());
784 }
785
786 #[test]
787 fn resume_tail_resets_offset() {
788 let mut pane = LogPane::new(None, LogTab::Errors, 10);
789 pane.scroll_up(7);
790 pane.resume_tail();
791 assert!(pane.is_tailing());
792 }
793
794 #[test]
795 fn tab_switch_resets_scroll_offset() {
796 let mut pane = LogPane::new(None, LogTab::Errors, 10);
799 pane.scroll_up(4);
800 pane.next_tab();
801 assert_eq!(pane.scroll_offset(), 0);
802 assert!(pane.is_tailing());
803 pane.scroll_up(4);
805 pane.prev_tab();
806 assert_eq!(pane.scroll_offset(), 0);
807 }
808
809 #[test]
810 fn push_bee_bumps_offset_for_active_tab_when_scrolled() {
811 let mut pane = LogPane::new(None, LogTab::Errors, 10);
815 pane.push_bee(LogTab::Errors, line("err1"));
816 pane.push_bee(LogTab::Errors, line("err2"));
817 pane.scroll_up(2);
818 pane.push_bee(LogTab::Errors, line("err3"));
819 assert_eq!(pane.scroll_offset(), 3);
822 }
823
824 #[test]
825 fn push_bee_doesnt_bump_offset_when_tailing() {
826 let mut pane = LogPane::new(None, LogTab::Errors, 10);
829 for i in 0..5 {
830 pane.push_bee(LogTab::Errors, line(&format!("e{i}")));
831 }
832 assert_eq!(pane.scroll_offset(), 0);
833 assert!(pane.is_tailing());
834 }
835
836 #[test]
837 fn push_bee_doesnt_bump_offset_for_inactive_tab() {
838 let mut pane = LogPane::new(None, LogTab::Errors, 10);
841 pane.push_bee(LogTab::Errors, line("err1"));
842 pane.scroll_up(1);
843 let before = pane.scroll_offset();
844 pane.push_bee(LogTab::Debug, line("dbg1"));
845 assert_eq!(pane.scroll_offset(), before);
846 }
847
848 fn line(msg: &str) -> BeeLogLine {
849 BeeLogLine {
850 timestamp: "t".into(),
851 logger: "node/test".into(),
852 message: msg.into(),
853 }
854 }
855
856 #[test]
857 fn ring_capacity_is_enforced() {
858 let mut pane = LogPane::new(None, LogTab::Debug, 10);
859 for i in 0..(BEE_TAB_RING_CAPACITY + 100) {
861 pane.push_bee(
862 LogTab::Debug,
863 BeeLogLine {
864 timestamp: format!("t{i}"),
865 logger: "node/test".into(),
866 message: format!("msg {i}"),
867 },
868 );
869 }
870 assert_eq!(pane.bee_buffers.debug.len(), BEE_TAB_RING_CAPACITY);
871 assert_eq!(pane.bee_buffers.debug.front().unwrap().timestamp, "t100");
872 assert_eq!(
873 pane.bee_buffers.debug.back().unwrap().timestamp,
874 format!("t{}", BEE_TAB_RING_CAPACITY + 99)
875 );
876 }
877
878 #[test]
879 fn push_bee_to_self_http_is_noop() {
880 let mut pane = LogPane::new(None, LogTab::SelfHttp, 10);
884 pane.push_bee(
885 LogTab::SelfHttp,
886 BeeLogLine {
887 timestamp: "t".into(),
888 logger: "x".into(),
889 message: "m".into(),
890 },
891 );
892 for tab in LogTab::ALL {
893 assert_eq!(pane.bee_buffers.count(tab), 0, "tab {tab:?} got an entry");
894 }
895 }
896
897 #[test]
898 fn human_count_formats_thousands() {
899 assert_eq!(human_count(0), "0");
900 assert_eq!(human_count(42), "42");
901 assert_eq!(human_count(999), "999");
902 assert_eq!(human_count(1000), "1.0k");
903 assert_eq!(human_count(1234), "1.2k");
904 assert_eq!(human_count(999_999), "1000.0k");
905 assert_eq!(human_count(1_000_000), "1.0m");
906 }
907
908 #[test]
909 fn from_kebab_unknown_falls_back_to_self_http() {
910 assert_eq!(LogTab::from_kebab("future-tab"), LogTab::SelfHttp);
913 assert_eq!(LogTab::from_kebab(""), LogTab::SelfHttp);
914 }
915
916 #[test]
917 fn kebab_round_trips() {
918 for tab in LogTab::ALL {
919 assert_eq!(LogTab::from_kebab(tab.to_kebab()), tab);
920 }
921 }
922
923 #[test]
924 fn path_only_strips_scheme_and_host() {
925 assert_eq!(path_only("http://localhost:1633/status"), "/status");
926 assert_eq!(
927 path_only("https://bee.example.com:1633/stamps/abc"),
928 "/stamps/abc"
929 );
930 }
931
932 #[test]
933 fn path_only_handles_root_only() {
934 assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
935 }
936
937 #[test]
938 fn h_scroll_starts_at_zero() {
939 let pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
940 assert_eq!(pane.h_scroll_offset(), 0);
941 }
942
943 #[test]
944 fn scroll_right_then_left_returns_to_zero() {
945 let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
946 pane.scroll_right(8);
947 pane.scroll_right(8);
948 assert_eq!(pane.h_scroll_offset(), 16);
949 pane.scroll_left(16);
950 assert_eq!(pane.h_scroll_offset(), 0);
951 }
952
953 #[test]
954 fn scroll_left_saturates_at_zero() {
955 let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
956 pane.scroll_left(100);
957 assert_eq!(pane.h_scroll_offset(), 0);
958 }
959
960 #[test]
961 fn switching_tabs_resets_h_scroll() {
962 let mut pane = LogPane::new(None, LogTab::Errors, LOG_PANE_MIN_HEIGHT);
963 pane.scroll_right(40);
964 assert_eq!(pane.h_scroll_offset(), 40);
965 pane.next_tab();
966 assert_eq!(pane.h_scroll_offset(), 0);
967 pane.scroll_right(20);
968 pane.prev_tab();
969 assert_eq!(pane.h_scroll_offset(), 0);
970 }
971
972 #[test]
973 fn resume_tail_resets_both_axes() {
974 let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
975 pane.scroll_up(5);
976 pane.scroll_right(24);
977 pane.resume_tail();
978 assert_eq!(pane.scroll_offset(), 0);
979 assert_eq!(pane.h_scroll_offset(), 0);
980 }
981
982 #[test]
983 fn reset_h_scroll_only_touches_horizontal() {
984 let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
985 pane.scroll_up(7);
986 pane.scroll_right(16);
987 pane.reset_h_scroll();
988 assert_eq!(pane.scroll_offset(), 7);
989 assert_eq!(pane.h_scroll_offset(), 0);
990 }
991}