refactor: reorganize tests/tools, share python protocol helpers
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-08-17 23:22:44 +08:00
parent 5f5477d658
commit 2a23168728
9 changed files with 266 additions and 174 deletions

View File

@@ -30,5 +30,12 @@ jobs:
- name: Clippy - name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --workspace --all-targets -- -D warnings
- name: Smoke-check Python tools
run: |
python3 -m py_compile tools/*.py
for f in tools/test_plot.py tools/test_drone.py tools/test_ansi.py tools/stress_test.py; do
python3 "$f" --help >/dev/null
done
- name: Run tests - name: Run tests
run: cargo test --workspace run: cargo test --workspace

View File

@@ -229,7 +229,7 @@ return {
} }
``` ```
参考实现:`tests/lua_line_framer.lua`(按 `\n` 分割的行分帧器) 参考实现:`crates/pipeview-client/tests/fixtures/lua_line_framer.lua`(按 `\n` 分割的行分帧器)
### 解码器 API ### 解码器 API
@@ -363,11 +363,11 @@ RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui # 详细日志
crates/ crates/
pipeview-core/ # 传输、分帧、协议 pipeview-core/ # 传输、分帧、协议
pipeview-client/ # 会话管理、Lua 运行时 pipeview-client/ # 会话管理、Lua 运行时
tests/fixtures/ # Lua 测试夹具
pipeview-gui/ # egui 桌面应用 pipeview-gui/ # egui 桌面应用
examples/ # Lua 脚本示例 examples/ # Lua 脚本示例
drone_plot.lua # 飞控遥测波形解码器 drone_plot.lua # 飞控遥测波形解码器
drone_text.lua # 飞控遥测文本解码器 drone_text.lua # 飞控遥测文本解码器
tests/ # Lua 测试 fixture
tools/ # 开发辅助工具 tools/ # 开发辅助工具
test_plot.py # 波形测试数据生成器 test_plot.py # 波形测试数据生成器
test_drone.py # 飞控测试数据生成器 test_drone.py # 飞控测试数据生成器

View File

@@ -217,7 +217,7 @@ return {
} }
``` ```
Reference: `tests/lua_line_framer.lua` (line-based framer splitting on `\n`). Reference: `crates/pipeview-client/tests/fixtures/lua_line_framer.lua` (line-based framer splitting on `\n`).
### Decoder API ### Decoder API
@@ -347,11 +347,11 @@ RUST_LOG=pipeview_gui=debug cargo run -p pipeview-gui
crates/ crates/
pipeview-core/ # Transport, framing, protocol pipeview-core/ # Transport, framing, protocol
pipeview-client/ # Session management, Lua runtime pipeview-client/ # Session management, Lua runtime
tests/fixtures/ # Lua test fixtures
pipeview-gui/ # egui desktop application pipeview-gui/ # egui desktop application
examples/ # Lua script examples examples/ # Lua script examples
drone_plot.lua # Drone telemetry plot decoder drone_plot.lua # Drone telemetry plot decoder
drone_text.lua # Drone telemetry text decoder drone_text.lua # Drone telemetry text decoder
tests/ # Lua test fixtures
tools/ # Development utilities tools/ # Development utilities
test_plot.py # Waveform test data generator test_plot.py # Waveform test data generator
test_drone.py # Drone test data generator test_drone.py # Drone test data generator

View File

@@ -0,0 +1,66 @@
use std::fs;
use std::path::PathBuf;
use mlua::{Function, Lua, Table};
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
fn load_fixture(name: &str) -> (Lua, Table) {
let lua = Lua::new();
let src = fs::read_to_string(fixture_path(name)).unwrap();
let table: Table = lua.load(&src).set_name(name).eval().unwrap();
(lua, table)
}
#[test]
fn lua_line_framer_fixture_splits_crlf_and_lf() {
let (lua, table) = load_fixture("lua_line_framer.lua");
let feed: Function = table.get("feed").unwrap();
let input = lua.create_string(b"hello\r\nworld\n").unwrap();
let frames: Vec<String> = feed.call(input).unwrap();
assert_eq!(frames, vec!["hello", "world"]);
}
#[test]
fn lua_line_framer_fixture_handles_split_chunks_and_flush() {
let (lua, table) = load_fixture("lua_line_framer.lua");
let feed: Function = table.get("feed").unwrap();
let flush: Function = table.get("flush").unwrap();
let pending_len: Function = table.get("pending_len").unwrap();
let reset: Function = table.get("reset").unwrap();
let frames: Vec<String> = feed.call(lua.create_string(b"par").unwrap()).unwrap();
assert!(frames.is_empty());
assert_eq!(pending_len.call::<usize>(()).unwrap(), 3);
let frames: Vec<String> = feed.call(lua.create_string(b"tial\n").unwrap()).unwrap();
assert_eq!(frames, vec!["partial"]);
let flushed: Option<String> = flush.call(()).unwrap();
assert_eq!(flushed.as_deref(), None);
reset.call::<()>(()).unwrap();
assert_eq!(pending_len.call::<usize>(()).unwrap(), 0);
}
#[test]
fn lua_text_decoder_fixture_decodes_text_and_rejects_empty() {
let (lua, table) = load_fixture("lua_text_decoder.lua");
let decode: Function = table.get("decode").unwrap();
let decoded: Option<Table> = decode.call(lua.create_string(b"abc").unwrap()).unwrap();
let decoded = decoded.expect("non-empty frame should decode");
let kind: String = decoded.get("kind").unwrap();
let data: String = decoded.get("data").unwrap();
assert_eq!(kind, "text");
assert_eq!(data, "abc");
let empty: Option<Table> = decode.call(lua.create_string(b"").unwrap()).unwrap();
assert!(empty.is_none(), "empty frame should be rejected");
}

158
tools/pv_protocol.py Normal file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Shared wire-format helpers for pipeview test tools.
Keep these functions in sync with:
- crates/pipeview-core/src/frame/cobs.rs (cobs_encode)
- crates/pipeview-core/src/protocol/mixed.rs (XP plot packet header)
"""
import math
import struct
PLOT_ESCAPE = 0x1E
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
# XP plot packet header field values (see protocol/mixed.rs)
PLOT_PACKET_MAGIC = b"XP"
PLOT_PACKET_VERSION = 1
PLOT_FORMAT_IDS = {
"interleaved": 0,
"block": 1,
"xy": 2,
}
SAMPLE_TYPE_F32 = 8
ENDIAN_LITTLE = 0
def cobs_encode(payload: bytes) -> bytes:
"""Consistent Overhead Byte Stuffing encoder (matches Rust cobs_encode)."""
if not payload:
return b"\x01"
out = bytearray([0])
code_index = 0
code = 1
for byte in payload:
if byte == 0:
out[code_index] = code
code_index = len(out)
out.append(0)
code = 1
else:
out.append(byte)
code += 1
if code == 0xFF:
out[code_index] = code
code_index = len(out)
out.append(0)
code = 1
out[code_index] = code
return bytes(out)
def build_plot_payload(
sample_index: int,
channels: int,
plot_format: str,
samples_per_channel: int,
amplitude: float,
frequency_hz: float,
sample_rate_hz: float,
) -> bytes:
"""Build a raw little-endian f32 plot payload without any framing."""
if channels <= 0:
raise ValueError("channels must be >= 1")
if plot_format not in PLOT_FORMAT_IDS:
raise ValueError(f"unsupported plot format: {plot_format}")
if plot_format == "xy" and channels != 2:
raise ValueError("xy format requires exactly 2 channels")
values = []
if plot_format == "xy":
for offset in range(samples_per_channel):
t = (sample_index + offset) / sample_rate_hz
values.extend(
[
amplitude * math.cos(2 * math.pi * frequency_hz * t),
amplitude * math.sin(2 * math.pi * frequency_hz * t),
]
)
elif plot_format == "block":
for ch in range(channels):
phase = 2 * math.pi * ch / channels
for offset in range(samples_per_channel):
t = (sample_index + offset) / sample_rate_hz
values.append(
amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
)
else: # interleaved
for offset in range(samples_per_channel):
t = (sample_index + offset) / sample_rate_hz
for ch in range(channels):
phase = 2 * math.pi * ch / channels
values.append(
amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
)
return struct.pack(f"<{len(values)}f", *values)
def build_plot_packet(
payload: bytes,
channels: int,
samples_per_channel: int,
plot_format: str,
) -> bytes:
"""Build an XP plot packet (the payload inside a MixedTextPlot COBS frame)."""
if not 1 <= channels <= 255:
raise ValueError("channels must be in 1..255")
if not 0 <= samples_per_channel <= 0xFFFF:
raise ValueError("samples_per_channel must fit in u16")
if plot_format not in PLOT_FORMAT_IDS:
raise ValueError(f"unsupported plot format: {plot_format}")
header = bytearray()
header.extend(PLOT_PACKET_MAGIC)
header.append(PLOT_PACKET_VERSION)
header.append(PLOT_FORMAT_IDS[plot_format])
header.append(SAMPLE_TYPE_F32)
header.append(ENDIAN_LITTLE)
header.append(channels)
header.extend(struct.pack("<H", samples_per_channel))
header.extend(struct.pack("<I", len(payload)))
header.extend(payload)
return bytes(header)
def build_mixed_plot_frame(
payload: bytes,
channels: int,
samples_per_channel: int,
plot_format: str,
) -> bytes:
"""Build a complete MixedTextPlot plot frame (escape + marker + COBS packet + 0x00)."""
packet = build_plot_packet(
payload=payload,
channels=channels,
samples_per_channel=samples_per_channel,
plot_format=plot_format,
)
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
def is_disconnect_error(err: OSError) -> bool:
"""Return True when the socket error is an expected client-disconnect error."""
return isinstance(
err,
(
BrokenPipeError,
ConnectionAbortedError,
ConnectionResetError,
),
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
pipeview-tauri 极限压力测试脚本 pipeview GUIegui极限压力测试脚本
模拟各种高数据量场景,测试 Tauri 前端的渲染性能和稳定性: 模拟各种高数据量场景,测试 pipeview GUI 的渲染性能和稳定性:
- 文本洪水:大文本行 + ANSI 颜色 - 文本洪水:大文本行 + ANSI 颜色
- 十六进制洪水:大批量 hex dump - 十六进制洪水:大批量 hex dump
- 波形洪水:高频 plot 采样点 - 波形洪水:高频 plot 采样点
@@ -20,20 +20,11 @@ import argparse
import math import math
import random import random
import socket import socket
import struct
import sys
import threading import threading
import time import time
from collections import defaultdict from collections import defaultdict
PLOT_ESCAPE = 0x1E import pv_protocol
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
# ── ANSI Color Palette ───────────────────────────────────────────── # ── ANSI Color Palette ─────────────────────────────────────────────
@@ -108,57 +99,32 @@ def build_plot_frame(
frequency_hz: float, frequency_hz: float,
sample_rate_hz: float, sample_rate_hz: float,
) -> bytes: ) -> bytes:
"""Build a raw COBS-encoded plot frame (matching test_plot.py format).""" """Build a raw little-endian f32 plot payload (no framing)."""
values = [] return pv_protocol.build_plot_payload(
if plot_format == "xy": sample_index=sample_index,
for _ in range(samples_per_channel): channels=channels,
t = (sample_index * samples_per_channel + len(values)) / sample_rate_hz plot_format=plot_format,
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t)) samples_per_channel=samples_per_channel,
values.append(amplitude * math.cos(2 * math.pi * frequency_hz * t)) amplitude=amplitude,
elif plot_format == "block": frequency_hz=frequency_hz,
for ch in range(channels): sample_rate_hz=sample_rate_hz,
phase = 2 * math.pi * ch / channels )
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
else: # interleaved
for _ in range(samples_per_channel):
t = (sample_index * samples_per_channel + len(values) // channels) / sample_rate_hz
for ch in range(channels):
phase = 2 * math.pi * ch / channels
values.append(amplitude * math.sin(2 * math.pi * frequency_hz * t + phase))
# Pack as f32 little-endian
payload = struct.pack(f"<{len(values)}f", *values)
return cobs_encode(payload)
def cobs_encode(data: bytes) -> bytes:
"""Consistent Overhead Byte Stuffing encoder."""
result = bytearray()
block_start = 0
while block_start < len(data):
end = min(block_start + 254, len(data))
block = data[block_start:end]
if end < len(data):
result.append(len(block) + 1)
result.extend(block)
else:
result.append(len(block) + 1 if len(block) < 254 else 255)
result.extend(block)
result.append(0)
block_start = end
return bytes(result)
def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes: def build_mixed_frame(seq: int, text_rate_per_plot: int, channels: int) -> bytes:
"""Build a MixedTextPlot frame: text lines + one COBS plot frame.""" """Build a MixedTextPlot frame: text lines + one XP plot frame."""
frames = [] frames = []
for i in range(text_rate_per_plot): for i in range(text_rate_per_plot):
frames.append(generate_text_line(seq * text_rate_per_plot + i, ansi=True)) frames.append(generate_text_line(seq * text_rate_per_plot + i, ansi=True))
# Add one plot frame per text_rate_per_plot text lines plot_payload = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0)
plot_data = build_plot_frame(seq, channels, "interleaved", 32, 100.0, 10.0, 1000.0) frames.append(
frames.append(bytes([PLOT_ESCAPE, PLOT_MARKER]) + plot_data) pv_protocol.build_mixed_plot_frame(
payload=plot_payload,
channels=channels,
samples_per_channel=32,
plot_format="interleaved",
)
)
return b"".join(frames) return b"".join(frames)
@@ -273,7 +239,7 @@ class StressServer:
last_count_time = time.time() last_count_time = time.time()
except OSError as e: except OSError as e:
if hasattr(e, "winerror") and e.winerror in DISCONNECT_WINERRORS: if pv_protocol.is_disconnect_error(e):
pass pass
elif not self.running: elif not self.running:
pass pass
@@ -324,7 +290,7 @@ class StressServer:
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="pipeview-tauri 极限压力测试", description="pipeview GUIegui极限压力测试",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
示例: 示例:

View File

@@ -4,115 +4,10 @@
import argparse import argparse
import math import math
import socket import socket
import struct
import threading import threading
import time import time
PLOT_ESCAPE = 0x1E import pv_protocol
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
def build_frame(
sample_index: int,
channels: int,
plot_format: str,
samples_per_channel: int,
amplitude: float,
frequency_hz: float,
sample_rate_hz: float,
) -> bytes:
values = []
if plot_format == "xy":
for offset in range(samples_per_channel):
t = (sample_index + offset) / sample_rate_hz
x = amplitude * math.cos(2 * math.pi * frequency_hz * t)
y = amplitude * math.sin(2 * math.pi * frequency_hz * t)
values.extend([x, y])
return struct.pack(f"<{len(values)}f", *values)
for offset in range(samples_per_channel):
t = (sample_index + offset) / sample_rate_hz
for ch in range(channels):
phase = 2 * math.pi * ch / max(channels, 1)
value = amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
values.append(value)
return struct.pack(f"<{len(values)}f", *values)
def cobs_encode(payload: bytes) -> bytes:
if not payload:
return b"\x01"
out = bytearray([0])
code_index = 0
code = 1
for byte in payload:
if byte == 0:
out[code_index] = code
code_index = len(out)
out.append(0)
code = 1
else:
out.append(byte)
code += 1
if code == 0xFF:
out[code_index] = code
code_index = len(out)
out.append(0)
code = 1
out[code_index] = code
return bytes(out)
def build_plot_packet(
payload: bytes,
channels: int,
samples_per_channel: int,
plot_format: str,
) -> bytes:
format_id = {"interleaved": 0, "block": 1, "xy": 2}[plot_format]
header = bytearray()
header.extend(b"XP")
header.append(1) # version
header.append(format_id)
header.append(8) # f32
header.append(0) # little-endian
header.append(channels)
header.extend(struct.pack("<H", samples_per_channel))
header.extend(struct.pack("<I", len(payload)))
header.extend(payload)
return bytes(header)
def build_mixed_plot_frame(
payload: bytes,
channels: int,
samples_per_channel: int,
plot_format: str,
) -> bytes:
packet = build_plot_packet(
payload=payload,
channels=channels,
samples_per_channel=samples_per_channel,
plot_format=plot_format,
)
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
def is_disconnect_error(err: OSError) -> bool:
return isinstance(
err,
(
BrokenPipeError,
ConnectionAbortedError,
ConnectionResetError,
),
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
def main() -> None: def main() -> None:
@@ -217,8 +112,8 @@ def main() -> None:
else: else:
print(" framer = Line") print(" framer = Line")
print(" decoder = Text") print(" decoder = Text")
print(" lua test = Lua framer tests/lua_line_framer.lua") print(" lua test = Lua framer crates/pipeview-client/tests/fixtures/lua_line_framer.lua")
print(" Lua decoder tests/lua_text_decoder.lua\n") print(" Lua decoder crates/pipeview-client/tests/fixtures/lua_text_decoder.lua\n")
if args.wire_format == "text": if args.wire_format == "text":
print(f"sending text only at {args.text_interval:.3f}s intervals") print(f"sending text only at {args.text_interval:.3f}s intervals")
print(f"sample clock: {args.rate:.1f} samples/sec\n") print(f"sample clock: {args.rate:.1f} samples/sec\n")
@@ -268,7 +163,7 @@ def main() -> None:
next_text_at += args.text_interval next_text_at += args.text_interval
continue continue
plot_payload = build_frame( plot_payload = pv_protocol.build_plot_payload(
sample_index=sample_index, sample_index=sample_index,
channels=args.channels, channels=args.channels,
plot_format=args.format, plot_format=args.format,
@@ -278,7 +173,7 @@ def main() -> None:
sample_rate_hz=args.rate, sample_rate_hz=args.rate,
) )
if args.wire_format == "mixed": if args.wire_format == "mixed":
frame = build_mixed_plot_frame( frame = pv_protocol.build_mixed_plot_frame(
payload=plot_payload, payload=plot_payload,
channels=args.channels, channels=args.channels,
samples_per_channel=samples_per_channel, samples_per_channel=samples_per_channel,
@@ -308,7 +203,7 @@ def main() -> None:
if wait > 0: if wait > 0:
time.sleep(wait) time.sleep(wait)
except OSError as err: except OSError as err:
if not is_disconnect_error(err): if not pv_protocol.is_disconnect_error(err):
raise raise
if args.wire_format == "text": if args.wire_format == "text":
print(f"[conn -] {addr} ({text_count} text lines)") print(f"[conn -] {addr} ({text_count} text lines)")