test: add CI, RingBuffer tests, core proptest, RAII lua fixtures

This commit is contained in:
2026-08-17 21:19:33 +08:00
parent 81c0ce13c4
commit 5f5477d658
12 changed files with 450 additions and 53 deletions

View File

@@ -15,3 +15,4 @@ thiserror = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
tracing-subscriber = { workspace = true }
tempfile = "3"

View File

@@ -57,3 +57,116 @@ impl RingBuffer<DecodedEntry> {
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::DecodedEntry;
use pipeview_core::protocol::DecodedData;
#[test]
fn push_evicts_oldest_when_limit_reached() {
let mut rb = RingBuffer::new(3);
rb.push(1);
rb.push(2);
rb.push(3);
rb.push(4);
rb.push(5);
assert_eq!(rb.len(), 3);
assert_eq!(rb.get(0), Some(&3));
assert_eq!(rb.get(1), Some(&4));
assert_eq!(rb.get(2), Some(&5));
assert_eq!(rb.get(3), None);
}
#[test]
fn iter_yields_oldest_to_newest() {
let mut rb = RingBuffer::new(3);
rb.push("a");
rb.push("b");
rb.push("c");
let items: Vec<_> = rb.iter().collect();
assert_eq!(items, vec![&"a", &"b", &"c"]);
}
#[test]
fn clear_empties_buffer() {
let mut rb = RingBuffer::new(3);
rb.push(1);
rb.push(2);
rb.clear();
assert!(rb.is_empty());
assert_eq!(rb.len(), 0);
assert_eq!(rb.get(0), None);
}
#[test]
fn set_limit_trims_excess() {
let mut rb = RingBuffer::new(5);
for i in 0..5 {
rb.push(i);
}
rb.set_limit(2);
assert_eq!(rb.len(), 2);
assert_eq!(rb.get(0), Some(&3));
assert_eq!(rb.get(1), Some(&4));
}
#[test]
fn set_limit_can_grow_without_data_loss() {
let mut rb = RingBuffer::new(2);
rb.push(1);
rb.push(2);
rb.set_limit(5);
assert_eq!(rb.len(), 2);
assert_eq!(rb.get(0), Some(&1));
assert_eq!(rb.get(1), Some(&2));
}
#[test]
fn drain_recent_respects_count_and_order() {
let mut rb = RingBuffer::new(5);
for i in 0..5 {
rb.push(i);
}
let empty: Vec<i32> = rb.drain_recent(0);
assert!(empty.is_empty());
let last_two = rb.drain_recent(2);
assert_eq!(last_two, vec![3, 4]);
let all = rb.drain_recent(10);
assert_eq!(all, vec![0, 1, 2, 3, 4]);
}
#[test]
fn entries_for_pipeline_filters_by_name() {
let mut rb = RingBuffer::new(10);
rb.push(DecodedEntry {
pipeline_name: "a".into(),
data: DecodedData::Text("one".into()),
});
rb.push(DecodedEntry {
pipeline_name: "b".into(),
data: DecodedData::Text("two".into()),
});
rb.push(DecodedEntry {
pipeline_name: "a".into(),
data: DecodedData::Text("three".into()),
});
let a = rb.entries_for_pipeline("a");
assert_eq!(a.len(), 2);
assert_eq!(a[0].pipeline_name, "a");
assert_eq!(a[1].pipeline_name, "a");
let none = rb.entries_for_pipeline("missing");
assert!(none.is_empty());
}
}

View File

@@ -1,6 +1,7 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Once;
use std::time::Duration;
use std::{fs, path::PathBuf};
use mlua::Lua;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -17,14 +18,30 @@ fn init_tracing() {
});
}
fn write_temp_lua(name: &str, script: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("pipeview_{name}_{nonce}.lua"));
fs::write(&path, script).unwrap();
path
struct TempScript {
path: PathBuf,
_guard: tempfile::TempPath,
}
impl TempScript {
fn new(name: &str, script: &str) -> Self {
let mut file = tempfile::Builder::new()
.prefix(&format!("pipeview_{name}_"))
.suffix(".lua")
.tempfile()
.unwrap();
file.write_all(script.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let guard = file.into_temp_path();
Self {
path,
_guard: guard,
}
}
fn path(&self) -> &Path {
&self.path
}
}
fn text_pipeline_lua() -> &'static str {
@@ -208,7 +225,7 @@ async fn lua_session_custom_lua_pipeline() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let framer_path = write_temp_lua(
let framer_script = TempScript::new(
"custom_framer",
r#"
local buffer = ""
@@ -239,7 +256,7 @@ async fn lua_session_custom_lua_pipeline() {
}
"#,
);
let decoder_path = write_temp_lua(
let decoder_script = TempScript::new(
"custom_decoder",
r#"
return {
@@ -287,14 +304,12 @@ async fn lua_session_custom_lua_pipeline() {
sess:close()
"#,
addr,
framer_path.display(),
decoder_path.display()
framer_script.path().display(),
decoder_script.path().display()
);
lua.load(&script).exec_async().await.unwrap();
server.await.unwrap();
fs::remove_file(framer_path).unwrap();
fs::remove_file(decoder_path).unwrap();
}
// ── session:on_data callback ─────────────────────────────────────

View File

@@ -15,3 +15,6 @@ serde_json = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
hex = "0.4"
[dev-dependencies]
proptest = "1"

View File

@@ -0,0 +1,96 @@
use proptest::prelude::*;
use pipeview_core::frame::Framer;
use pipeview_core::frame::cobs::{CobsFramer, cobs_decode, cobs_encode};
use pipeview_core::frame::length::{LengthConfig, LengthPrefixedFramer};
use pipeview_core::protocol::Endian;
use pipeview_core::protocol::ProtocolDecoder;
use pipeview_core::protocol::hex::{HexConfig, HexDecoder};
use pipeview_core::protocol::plot::{PlotConfig, PlotDecoder, SampleType};
use pipeview_core::protocol::text::{TextDecoder, TextEncoding};
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
// ── COBS ─────────────────────────────────────────────────────────
#[test]
fn cobs_roundtrip_any_payload(payload in prop::collection::vec(any::<u8>(), 0..512)) {
let encoded = cobs_encode(&payload);
let decoded = cobs_decode(&encoded).unwrap();
prop_assert_eq!(&decoded, &payload);
let mut framer = CobsFramer::new(1024 * 1024);
let mut wire = encoded;
wire.push(0x00);
let frames = framer.feed(&wire);
prop_assert_eq!(frames.len(), 1);
prop_assert_eq!(&frames[0], &payload);
}
// ── Length-prefixed ──────────────────────────────────────────────
#[test]
fn length_roundtrip_any_payload(
len_bytes in prop_oneof![Just(1usize), Just(2usize), Just(4usize)],
little_endian in any::<bool>(),
includes_self in any::<bool>(),
payload in prop::collection::vec(any::<u8>(), 0..200)
) {
let endian = if little_endian { Endian::Little } else { Endian::Big };
let raw_len = if includes_self {
payload.len() + len_bytes
} else {
payload.len()
};
let mut wire = Vec::with_capacity(len_bytes + payload.len());
match (len_bytes, endian) {
(1, _) => wire.push(raw_len as u8),
(2, Endian::Big) => wire.extend_from_slice(&(raw_len as u16).to_be_bytes()),
(2, Endian::Little) => wire.extend_from_slice(&(raw_len as u16).to_le_bytes()),
(4, Endian::Big) => wire.extend_from_slice(&(raw_len as u32).to_be_bytes()),
(4, Endian::Little) => wire.extend_from_slice(&(raw_len as u32).to_le_bytes()),
_ => unreachable!(),
}
wire.extend_from_slice(&payload);
let config = LengthConfig {
len_bytes,
endian,
length_includes_self: includes_self,
max_payload: 1024 * 1024,
};
let mut framer = LengthPrefixedFramer::new(config);
let frames = framer.feed(&wire);
prop_assert_eq!(frames.len(), 1);
prop_assert_eq!(&frames[0], &payload);
}
// ── Protocol decoders never panic on arbitrary bytes ─────────────
#[test]
fn plot_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
let decoder = PlotDecoder::new(PlotConfig {
sample_type: SampleType::F32,
endian: Endian::Little,
channels: 2,
format: pipeview_core::protocol::plot::PlotFormat::Interleaved,
});
let _ = decoder.decode(&bytes);
}
#[test]
fn hex_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
let decoder = HexDecoder::new(HexConfig::default());
let decoded = decoder.decode(&bytes);
prop_assert!(decoded.is_some());
}
#[test]
fn latin1_decode_arbitrary_bytes_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..256)) {
let decoder = TextDecoder::new(TextEncoding::Latin1);
let decoded = decoder.decode(&bytes);
prop_assert!(decoded.is_some());
}
}

View File

@@ -4,7 +4,9 @@ use std::time::{Duration, Instant};
use crate::buffers::{HexBuffer, PlotBuffer, TextBuffer};
use crate::logging::{self, LogWriter};
use crate::panels::{config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar};
use crate::panels::{
config, console, font_settings, hex_view, plot_view, send_panel, session_controls, sidebar,
};
use crate::perf::{DrainStats, GuiProfiler, GuiSnapshot};
use crate::shortcuts::{self, default_bindings};
use crate::ui_fonts::{self, FontCandidate, UiFontSettings};
@@ -557,7 +559,10 @@ impl XserialApp {
let (badge, status_text) = tab.status.badge();
ui.horizontal(|ui| {
ui.add(Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading()).selectable(false));
ui.add(
Label::new(egui::RichText::new(format!("Session {}", tab.id)).heading())
.selectable(false),
);
ui.add(Label::new(badge).selectable(false));
ui.separator();
ui.add(Label::new(status_text).selectable(false));
@@ -579,7 +584,8 @@ impl XserialApp {
}
});
let auto_reconnect_changed = session_controls::render_session_controls(ui, &manager, tab);
let auto_reconnect_changed =
session_controls::render_session_controls(ui, &manager, tab);
persist_state |= auto_reconnect_changed;
let mut display_changed = false;
@@ -598,7 +604,10 @@ impl XserialApp {
session_controls::render_search_bar(ui, tab);
if let ConnectionStatus::Error(message) = &tab.status {
ui.add(Label::new(egui::RichText::new(message).color(Color32::RED)).selectable(false));
ui.add(
Label::new(egui::RichText::new(message).color(Color32::RED))
.selectable(false),
);
}
let full = ui.available_rect_before_wrap();
@@ -620,11 +629,21 @@ impl XserialApp {
let started = Instant::now();
match tab.view {
View::Text => {
let line_count = console::render(ui, &tab.console, *display, tab.search.active.then_some(&tab.search));
let line_count = console::render(
ui,
&tab.console,
*display,
tab.search.active.then_some(&tab.search),
);
text_render = Some((started.elapsed(), line_count));
}
View::Hex => {
let line_count = hex_view::render(ui, &tab.hex, *display, tab.search.active.then_some(&tab.search));
let line_count = hex_view::render(
ui,
&tab.hex,
*display,
tab.search.active.then_some(&tab.search),
);
hex_render = Some((started.elapsed(), line_count));
}
View::Plot => {

View File

@@ -37,8 +37,7 @@ pub fn render(
input.pointer.button_down(egui::PointerButton::Primary)
&& input.pointer.hover_pos().is_some_and(|pos| {
let area = ui.max_rect();
pos.y > area.bottom() - EDGE_SCROLL_ZONE
&& pos.y < area.bottom() + 20.0
pos.y > area.bottom() - EDGE_SCROLL_ZONE && pos.y < area.bottom() + 20.0
})
});
@@ -56,9 +55,8 @@ pub fn render(
// offset for the next frame and request a repaint for smooth continuous scrolling.
if near_bottom_edge && line_count > 0 {
let mut state = output.state;
let max_offset = (line_count as f32 * row_height)
- output.inner_rect.height()
+ EDGE_SCROLL_ZONE;
let max_offset =
(line_count as f32 * row_height) - output.inner_rect.height() + EDGE_SCROLL_ZONE;
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
state.store(ui.ctx(), output.id);
ui.ctx().request_repaint();
@@ -74,7 +72,7 @@ fn monospace_format(style: &egui::Style) -> TextFormat {
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_default(),
color: Color32::WHITE,
color: Color32::WHITE,
..Default::default()
}
}

View File

@@ -38,8 +38,7 @@ pub fn render(
input.pointer.button_down(egui::PointerButton::Primary)
&& input.pointer.hover_pos().is_some_and(|pos| {
let area = ui.max_rect();
pos.y > area.bottom() - EDGE_SCROLL_ZONE
&& pos.y < area.bottom() + 20.0
pos.y > area.bottom() - EDGE_SCROLL_ZONE && pos.y < area.bottom() + 20.0
})
});
@@ -57,9 +56,8 @@ pub fn render(
// offset for the next frame and request a repaint for smooth continuous scrolling.
if near_bottom_edge {
let mut state = output.state;
let max_offset = (line_count as f32 * row_height)
- output.inner_rect.height()
+ EDGE_SCROLL_ZONE;
let max_offset =
(line_count as f32 * row_height) - output.inner_rect.height() + EDGE_SCROLL_ZONE;
state.offset.y = (state.offset.y + EDGE_SCROLL_NUDGE).min(max_offset.max(0.0));
state.store(ui.ctx(), output.id);
ui.ctx().request_repaint();

View File

@@ -44,18 +44,14 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut
let log_toggled = ui
.horizontal(|ui| {
let toggled = ui
.checkbox(&mut tab.log_enabled, "Log to file")
.changed();
let toggled = ui.checkbox(&mut tab.log_enabled, "Log to file").changed();
ui.checkbox(&mut tab.show_sent, "Show sent data");
toggled
})
.inner;
if log_toggled {
if tab.log_enabled {
tab.log_path = default_log_path(tab.id)
.to_string_lossy()
.to_string();
tab.log_path = default_log_path(tab.id).to_string_lossy().to_string();
tab.log_writer = LogWriter::open(&tab.log_path).ok();
} else {
tab.log_writer = None;
@@ -63,9 +59,7 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut
}
if tab.log_enabled {
ui.horizontal(|ui| {
let changed = ui
.text_edit_singleline(&mut tab.log_path)
.lost_focus();
let changed = ui.text_edit_singleline(&mut tab.log_path).lost_focus();
if changed && !tab.log_path.is_empty() {
tab.log_writer = LogWriter::open(&tab.log_path).ok();
}
@@ -96,13 +90,13 @@ pub fn render_send_panel(ui: &mut egui::Ui, manager: &SessionManager, tab: &mut
send_clicked = ui.button("Send").clicked();
if let Some(status) = &tab.send_status {
ui.add(
Label::new(
egui::RichText::new(status).color(if status.starts_with("Send failed") {
Label::new(egui::RichText::new(status).color(
if status.starts_with("Send failed") {
Color32::RED
} else {
Color32::GRAY
}),
)
},
))
.selectable(false),
);
}

View File

@@ -19,7 +19,9 @@ pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
}
if response.changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Text => tab
.console
.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};
@@ -48,7 +50,9 @@ pub fn render_search_bar(ui: &mut egui::Ui, tab: &mut SessionTab) {
if ui.checkbox(&mut tab.search.case_sensitive, "Aa").changed() {
let matches = match tab.view {
View::Text => tab.console.search(&tab.search.query, tab.search.case_sensitive),
View::Text => tab
.console
.search(&tab.search.query, tab.search.case_sensitive),
View::Hex => tab.hex.search(&tab.search.query, tab.search.case_sensitive),
View::Plot => Vec::new(),
};