#![forbid(unsafe_code)]
use core::time::Duration;
use ftui_core::event::{
ClipboardEvent, ClipboardSource, Event, ImeEvent, ImePhase, KeyCode, KeyEvent, KeyEventKind,
Modifiers, MouseButton, MouseEvent, MouseEventKind, PasteEvent,
};
use ftui_runtime::render_trace::checksum_buffer;
use crate::WebBackendError;
use crate::step_program::{StepProgram, StepResult};
#[cfg(feature = "tracing")]
use tracing::{error, info_span};
pub const SCHEMA_VERSION: &str = "golden-trace-v1";
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
fn fnv1a64_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
fn fnv1a64_u64(hash: u64, v: u64) -> u64 {
fnv1a64_bytes(hash, &v.to_le_bytes())
}
fn fnv1a64_pair(prev: u64, next: u64) -> u64 {
let hash = FNV_OFFSET_BASIS;
let hash = fnv1a64_u64(hash, prev);
fnv1a64_u64(hash, next)
}
#[derive(Debug, Clone, PartialEq)]
pub enum TraceRecord {
Header {
seed: u64,
cols: u16,
rows: u16,
profile: String,
},
Input { ts_ns: u64, event: Event },
Resize { ts_ns: u64, cols: u16, rows: u16 },
Tick { ts_ns: u64 },
Frame {
frame_idx: u64,
ts_ns: u64,
checksum: u64,
checksum_chain: u64,
},
Summary {
total_frames: u64,
final_checksum_chain: u64,
},
}
#[derive(Debug, Clone)]
pub struct SessionTrace {
pub records: Vec<TraceRecord>,
}
impl SessionTrace {
pub fn frame_count(&self) -> u64 {
self.records
.iter()
.filter(|r| matches!(r, TraceRecord::Frame { .. }))
.count() as u64
}
pub fn final_checksum_chain(&self) -> Option<u64> {
self.records.iter().rev().find_map(|r| match r {
TraceRecord::Summary {
final_checksum_chain,
..
} => Some(*final_checksum_chain),
_ => None,
})
}
pub fn validate(&self) -> Result<(), TraceValidationError> {
if self.records.is_empty() {
return Err(TraceValidationError::EmptyTrace);
}
let mut header_count: usize = 0;
let mut summary: Option<(usize, u64, u64)> = None;
let mut expected_frame_idx: u64 = 0;
let mut frame_count: u64 = 0;
let mut last_checksum_chain: u64 = 0;
let mut last_ts_ns: Option<u64> = None;
let mut validate_ts =
|ts_ns: u64, record_index: usize| -> Result<(), TraceValidationError> {
if let Some(previous) = last_ts_ns
&& ts_ns < previous
{
return Err(TraceValidationError::TimestampRegression {
previous,
current: ts_ns,
record_index,
});
}
last_ts_ns = Some(ts_ns);
Ok(())
};
for (idx, record) in self.records.iter().enumerate() {
match record {
TraceRecord::Header { .. } => {
if summary.is_some() {
let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
return Err(TraceValidationError::SummaryNotLast {
summary_index: summary_idx,
});
}
header_count += 1;
}
TraceRecord::Summary {
total_frames,
final_checksum_chain,
} => {
if summary.is_some() {
return Err(TraceValidationError::MultipleSummaries);
}
summary = Some((idx, *total_frames, *final_checksum_chain));
}
TraceRecord::Frame {
frame_idx,
ts_ns,
checksum_chain,
..
} => {
validate_ts(*ts_ns, idx)?;
if summary.is_some() {
let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
return Err(TraceValidationError::SummaryNotLast {
summary_index: summary_idx,
});
}
if *frame_idx != expected_frame_idx {
return Err(TraceValidationError::FrameIndexMismatch {
expected: expected_frame_idx,
actual: *frame_idx,
});
}
expected_frame_idx = expected_frame_idx.saturating_add(1);
frame_count = frame_count.saturating_add(1);
last_checksum_chain = *checksum_chain;
}
TraceRecord::Input { ts_ns, .. } => {
validate_ts(*ts_ns, idx)?;
if summary.is_some() {
let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
return Err(TraceValidationError::SummaryNotLast {
summary_index: summary_idx,
});
}
}
TraceRecord::Resize { ts_ns, .. } => {
validate_ts(*ts_ns, idx)?;
if summary.is_some() {
let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
return Err(TraceValidationError::SummaryNotLast {
summary_index: summary_idx,
});
}
}
TraceRecord::Tick { ts_ns } => {
validate_ts(*ts_ns, idx)?;
if summary.is_some() {
let summary_idx = summary.map(|(i, _, _)| i).unwrap_or_default();
return Err(TraceValidationError::SummaryNotLast {
summary_index: summary_idx,
});
}
}
}
}
if header_count == 0 {
return Err(TraceValidationError::MissingHeader);
}
if header_count > 1 {
return Err(TraceValidationError::MultipleHeaders);
}
if !matches!(self.records.first(), Some(TraceRecord::Header { .. })) {
return Err(TraceValidationError::HeaderNotFirst);
}
let Some((summary_idx, summary_frames, summary_chain)) = summary else {
return Err(TraceValidationError::MissingSummary);
};
if summary_idx != self.records.len().saturating_sub(1) {
return Err(TraceValidationError::SummaryNotLast {
summary_index: summary_idx,
});
}
if summary_frames != frame_count {
return Err(TraceValidationError::SummaryFrameCountMismatch {
expected: frame_count,
actual: summary_frames,
});
}
if summary_chain != last_checksum_chain {
return Err(TraceValidationError::SummaryChecksumChainMismatch {
expected: last_checksum_chain,
actual: summary_chain,
});
}
Ok(())
}
}
pub struct SessionRecorder<M: ftui_runtime::program::Model> {
program: StepProgram<M>,
records: Vec<TraceRecord>,
checksum_chain: u64,
current_ts_ns: u64,
}
impl<M: ftui_runtime::program::Model> SessionRecorder<M> {
#[must_use]
pub fn new(model: M, width: u16, height: u16, seed: u64) -> Self {
let program = StepProgram::new(model, width, height);
let records = vec![TraceRecord::Header {
seed,
cols: width,
rows: height,
profile: "modern".to_string(),
}];
Self {
program,
records,
checksum_chain: 0,
current_ts_ns: 0,
}
}
pub fn init(&mut self) -> Result<(), WebBackendError> {
self.program.init()?;
self.record_frame();
Ok(())
}
pub fn push_event(&mut self, ts_ns: u64, event: Event) {
self.current_ts_ns = ts_ns;
self.records.push(TraceRecord::Input {
ts_ns,
event: event.clone(),
});
self.program.push_event(event);
}
pub fn resize(&mut self, ts_ns: u64, width: u16, height: u16) {
self.current_ts_ns = ts_ns;
self.records.push(TraceRecord::Resize {
ts_ns,
cols: width,
rows: height,
});
self.program.resize(width, height);
}
pub fn advance_time(&mut self, ts_ns: u64, dt: Duration) {
self.current_ts_ns = ts_ns;
self.records.push(TraceRecord::Tick { ts_ns });
self.program.advance_time(dt);
}
pub fn step(&mut self) -> Result<StepResult, WebBackendError> {
let result = self.program.step()?;
if result.rendered {
self.record_frame();
}
Ok(result)
}
pub fn finish(mut self) -> SessionTrace {
let total_frames = self
.records
.iter()
.filter(|r| matches!(r, TraceRecord::Frame { .. }))
.count() as u64;
self.records.push(TraceRecord::Summary {
total_frames,
final_checksum_chain: self.checksum_chain,
});
SessionTrace {
records: self.records,
}
}
pub fn program(&self) -> &StepProgram<M> {
&self.program
}
pub fn program_mut(&mut self) -> &mut StepProgram<M> {
&mut self.program
}
fn record_frame(&mut self) {
let outputs = self.program.outputs();
if let Some(buf) = &outputs.last_buffer {
let checksum = checksum_buffer(buf, self.program.pool());
let chain = fnv1a64_pair(self.checksum_chain, checksum);
self.records.push(TraceRecord::Frame {
frame_idx: self.program.frame_idx().saturating_sub(1),
ts_ns: self.current_ts_ns,
checksum,
checksum_chain: chain,
});
self.checksum_chain = chain;
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplayResult {
pub total_frames: u64,
pub final_checksum_chain: u64,
pub first_mismatch: Option<ReplayMismatch>,
}
impl ReplayResult {
#[must_use]
pub fn ok(&self) -> bool {
self.first_mismatch.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplayMismatch {
pub frame_idx: u64,
pub expected: u64,
pub actual: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplayError {
MissingHeader,
InvalidTrace(TraceValidationError),
Backend(WebBackendError),
}
impl core::fmt::Display for ReplayError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::MissingHeader => write!(f, "trace missing header record"),
Self::InvalidTrace(e) => write!(f, "invalid trace: {e}"),
Self::Backend(e) => write!(f, "backend error: {e}"),
}
}
}
impl std::error::Error for ReplayError {}
impl From<WebBackendError> for ReplayError {
fn from(e: WebBackendError) -> Self {
Self::Backend(e)
}
}
pub fn replay<M: ftui_runtime::program::Model>(
model: M,
trace: &SessionTrace,
) -> Result<ReplayResult, ReplayError> {
let (cols, rows) = trace
.records
.first()
.and_then(|r| match r {
TraceRecord::Header { cols, rows, .. } => Some((*cols, *rows)),
_ => None,
})
.ok_or(ReplayError::MissingHeader)?;
trace.validate().map_err(ReplayError::InvalidTrace)?;
let mut program = StepProgram::new(model, cols, rows);
program.init()?;
let mut replay_frame_idx: u64 = 0;
let mut checksum_chain: u64 = 0;
let mut first_mismatch: Option<ReplayMismatch> = None;
for record in &trace.records {
match record {
TraceRecord::Input { event, .. } => {
program.push_event(event.clone());
}
TraceRecord::Resize { cols, rows, .. } => {
program.resize(*cols, *rows);
}
TraceRecord::Tick { ts_ns } => {
program.set_time(Duration::from_nanos(*ts_ns));
}
TraceRecord::Frame {
frame_idx: expected_idx,
checksum: expected_checksum,
..
} => {
if replay_frame_idx > 0 {
program.step()?;
}
let outputs = program.outputs();
if let Some(buf) = &outputs.last_buffer {
let actual = checksum_buffer(buf, program.pool());
checksum_chain = fnv1a64_pair(checksum_chain, actual);
if actual != *expected_checksum && first_mismatch.is_none() {
first_mismatch = Some(ReplayMismatch {
frame_idx: *expected_idx,
expected: *expected_checksum,
actual,
});
}
}
replay_frame_idx += 1;
}
TraceRecord::Header { .. } | TraceRecord::Summary { .. } => {}
}
}
Ok(ReplayResult {
total_frames: replay_frame_idx,
final_checksum_chain: checksum_chain,
first_mismatch,
})
}
fn json_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len() + 8);
for ch in input.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
use core::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
fn event_to_json(event: &Event) -> String {
match event {
Event::Key(k) => {
let code = key_code_to_str(k.code);
let mods = k.modifiers.bits();
let kind = key_event_kind_to_str(k.kind);
format!(
r#"{{"kind":"key","code":"{}","modifiers":{},"event_kind":"{}"}}"#,
json_escape(&code),
mods,
kind
)
}
Event::Mouse(m) => {
let kind = mouse_event_kind_to_str(m.kind);
let mods = m.modifiers.bits();
format!(
r#"{{"kind":"mouse","mouse_kind":"{}","x":{},"y":{},"modifiers":{}}}"#,
kind, m.x, m.y, mods
)
}
Event::Resize { width, height } => {
format!(
r#"{{"kind":"resize","width":{},"height":{}}}"#,
width, height
)
}
Event::Paste(p) => {
format!(
r#"{{"kind":"paste","text":"{}","bracketed":{}}}"#,
json_escape(&p.text),
p.bracketed
)
}
Event::Ime(ime) => {
format!(
r#"{{"kind":"ime","phase":"{}","text":"{}"}}"#,
ime_phase_to_str(ime.phase),
json_escape(&ime.text)
)
}
Event::Focus(gained) => {
format!(r#"{{"kind":"focus","gained":{}}}"#, gained)
}
Event::Clipboard(c) => {
let source = clipboard_source_to_str(c.source);
format!(
r#"{{"kind":"clipboard","content":"{}","source":"{}"}}"#,
json_escape(&c.content),
source
)
}
Event::Tick => r#"{"kind":"tick"}"#.to_string(),
}
}
fn key_code_to_str(code: KeyCode) -> String {
match code {
KeyCode::Char(c) => format!("char:{c}"),
KeyCode::Enter => "enter".to_string(),
KeyCode::Escape => "escape".to_string(),
KeyCode::Backspace => "backspace".to_string(),
KeyCode::Tab => "tab".to_string(),
KeyCode::BackTab => "backtab".to_string(),
KeyCode::Delete => "delete".to_string(),
KeyCode::Insert => "insert".to_string(),
KeyCode::Home => "home".to_string(),
KeyCode::End => "end".to_string(),
KeyCode::PageUp => "pageup".to_string(),
KeyCode::PageDown => "pagedown".to_string(),
KeyCode::Up => "up".to_string(),
KeyCode::Down => "down".to_string(),
KeyCode::Left => "left".to_string(),
KeyCode::Right => "right".to_string(),
KeyCode::F(n) => format!("f:{n}"),
KeyCode::Null => "null".to_string(),
KeyCode::MediaPlayPause => "media_play_pause".to_string(),
KeyCode::MediaStop => "media_stop".to_string(),
KeyCode::MediaNextTrack => "media_next".to_string(),
KeyCode::MediaPrevTrack => "media_prev".to_string(),
}
}
fn key_event_kind_to_str(kind: KeyEventKind) -> &'static str {
match kind {
KeyEventKind::Press => "press",
KeyEventKind::Repeat => "repeat",
KeyEventKind::Release => "release",
}
}
fn mouse_event_kind_to_str(kind: MouseEventKind) -> &'static str {
match kind {
MouseEventKind::Down(MouseButton::Left) => "down_left",
MouseEventKind::Down(MouseButton::Right) => "down_right",
MouseEventKind::Down(MouseButton::Middle) => "down_middle",
MouseEventKind::Up(MouseButton::Left) => "up_left",
MouseEventKind::Up(MouseButton::Right) => "up_right",
MouseEventKind::Up(MouseButton::Middle) => "up_middle",
MouseEventKind::Drag(MouseButton::Left) => "drag_left",
MouseEventKind::Drag(MouseButton::Right) => "drag_right",
MouseEventKind::Drag(MouseButton::Middle) => "drag_middle",
MouseEventKind::Moved => "moved",
MouseEventKind::ScrollUp => "scroll_up",
MouseEventKind::ScrollDown => "scroll_down",
MouseEventKind::ScrollLeft => "scroll_left",
MouseEventKind::ScrollRight => "scroll_right",
}
}
fn clipboard_source_to_str(source: ClipboardSource) -> &'static str {
match source {
ClipboardSource::Osc52 => "osc52",
ClipboardSource::Unknown => "unknown",
}
}
fn ime_phase_to_str(phase: ImePhase) -> &'static str {
match phase {
ImePhase::Start => "start",
ImePhase::Update => "update",
ImePhase::Commit => "commit",
ImePhase::Cancel => "cancel",
}
}
impl TraceRecord {
pub fn to_jsonl(&self) -> String {
match self {
TraceRecord::Header {
seed,
cols,
rows,
profile,
} => format!(
r#"{{"schema_version":"{}","event":"trace_header","seed":{},"cols":{},"rows":{},"env":{{"target":"web"}},"profile":"{}"}}"#,
SCHEMA_VERSION,
seed,
cols,
rows,
json_escape(profile)
),
TraceRecord::Input { ts_ns, event } => format!(
r#"{{"schema_version":"{}","event":"input","ts_ns":{},"data":{}}}"#,
SCHEMA_VERSION,
ts_ns,
event_to_json(event)
),
TraceRecord::Resize { ts_ns, cols, rows } => format!(
r#"{{"schema_version":"{}","event":"resize","ts_ns":{},"cols":{},"rows":{}}}"#,
SCHEMA_VERSION, ts_ns, cols, rows
),
TraceRecord::Tick { ts_ns } => format!(
r#"{{"schema_version":"{}","event":"tick","ts_ns":{}}}"#,
SCHEMA_VERSION, ts_ns
),
TraceRecord::Frame {
frame_idx,
ts_ns,
checksum,
checksum_chain,
} => format!(
r#"{{"schema_version":"{}","event":"frame","frame_idx":{},"ts_ns":{},"hash_algo":"fnv1a64","frame_hash":"{:016x}","checksum_chain":"{:016x}"}}"#,
SCHEMA_VERSION, frame_idx, ts_ns, checksum, checksum_chain
),
TraceRecord::Summary {
total_frames,
final_checksum_chain,
} => format!(
r#"{{"schema_version":"{}","event":"trace_summary","total_frames":{},"final_checksum_chain":"{:016x}"}}"#,
SCHEMA_VERSION, total_frames, final_checksum_chain
),
}
}
}
impl SessionTrace {
pub fn to_jsonl(&self) -> String {
let mut out = String::new();
for record in &self.records {
out.push_str(&record.to_jsonl());
out.push('\n');
}
out
}
pub fn from_jsonl(input: &str) -> Result<Self, TraceParseError> {
let mut records = Vec::new();
for (line_num, line) in input.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let record = parse_trace_line(line, line_num + 1)?;
records.push(record);
}
Ok(SessionTrace { records })
}
pub fn from_jsonl_validated(input: &str) -> Result<Self, TraceLoadError> {
let trace = Self::from_jsonl(input)?;
trace.validate()?;
Ok(trace)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceParseError {
pub line: usize,
pub message: String,
}
impl core::fmt::Display for TraceParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
impl std::error::Error for TraceParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceValidationError {
EmptyTrace,
MissingHeader,
HeaderNotFirst,
MultipleHeaders,
MissingSummary,
MultipleSummaries,
SummaryNotLast {
summary_index: usize,
},
TimestampRegression {
previous: u64,
current: u64,
record_index: usize,
},
FrameIndexMismatch {
expected: u64,
actual: u64,
},
SummaryFrameCountMismatch {
expected: u64,
actual: u64,
},
SummaryChecksumChainMismatch {
expected: u64,
actual: u64,
},
}
impl core::fmt::Display for TraceValidationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::EmptyTrace => write!(f, "trace is empty"),
Self::MissingHeader => write!(f, "trace is missing header"),
Self::HeaderNotFirst => write!(f, "trace header is not the first record"),
Self::MultipleHeaders => write!(f, "trace contains multiple headers"),
Self::MissingSummary => write!(f, "trace is missing summary"),
Self::MultipleSummaries => write!(f, "trace contains multiple summaries"),
Self::SummaryNotLast { summary_index } => write!(
f,
"trace summary at index {} is not the final record",
summary_index
),
Self::TimestampRegression {
previous,
current,
record_index,
} => write!(
f,
"timestamp regression at record {}: current ts_ns={} is less than previous ts_ns={}",
record_index, current, previous
),
Self::FrameIndexMismatch { expected, actual } => {
write!(
f,
"frame index mismatch: expected {}, got {}",
expected, actual
)
}
Self::SummaryFrameCountMismatch { expected, actual } => write!(
f,
"summary frame-count mismatch: expected {}, got {}",
expected, actual
),
Self::SummaryChecksumChainMismatch { expected, actual } => write!(
f,
"summary checksum-chain mismatch: expected {:016x}, got {:016x}",
expected, actual
),
}
}
}
impl std::error::Error for TraceValidationError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceLoadError {
Parse(TraceParseError),
Validation(TraceValidationError),
}
impl core::fmt::Display for TraceLoadError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Parse(e) => write!(f, "{e}"),
Self::Validation(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for TraceLoadError {}
impl From<TraceParseError> for TraceLoadError {
fn from(value: TraceParseError) -> Self {
Self::Parse(value)
}
}
impl From<TraceValidationError> for TraceLoadError {
fn from(value: TraceValidationError) -> Self {
Self::Validation(value)
}
}
fn extract_str<'a>(json: &'a str, key: &str) -> Option<&'a str> {
let pattern = format!("\"{}\":\"", key);
let start = json.find(&pattern)? + pattern.len();
let rest = &json[start..];
let mut i = 0;
let bytes = rest.as_bytes();
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2; continue;
}
if bytes[i] == b'"' {
return Some(&rest[..i]);
}
i += 1;
}
None
}
fn extract_u64(json: &str, key: &str) -> Option<u64> {
let pattern = format!("\"{}\":", key);
let start = json.find(&pattern)? + pattern.len();
let rest = json[start..].trim_start();
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[..end].parse().ok()
}
fn extract_i64(json: &str, key: &str) -> Option<i64> {
let pattern = format!("\"{}\":", key);
let start = json.find(&pattern)? + pattern.len();
let rest = json[start..].trim_start();
let signed = rest.strip_prefix('-').is_some();
let digits = if signed { &rest[1..] } else { rest };
let end = digits
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(digits.len());
if end == 0 {
return None;
}
let parsed: i64 = digits[..end].parse().ok()?;
Some(if signed { -parsed } else { parsed })
}
fn extract_u16(json: &str, key: &str) -> Option<u16> {
extract_u64(json, key).and_then(|v| u16::try_from(v).ok())
}
fn extract_bool(json: &str, key: &str) -> Option<bool> {
let pattern = format!("\"{}\":", key);
let start = json.find(&pattern)? + pattern.len();
let rest = json[start..].trim_start();
if rest.starts_with("true") {
Some(true)
} else if rest.starts_with("false") {
Some(false)
} else {
None
}
}
fn extract_hex_u64(json: &str, key: &str) -> Option<u64> {
let s = extract_str(json, key)?;
u64::from_str_radix(s, 16).ok()
}
fn extract_object<'a>(json: &'a str, key: &str) -> Option<&'a str> {
let pattern = format!("\"{}\":", key);
let start = json.find(&pattern)? + pattern.len();
let rest = json[start..].trim_start();
if !rest.starts_with('{') {
return None;
}
let mut depth = 0;
for (i, ch) in rest.char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(&rest[..=i]);
}
}
_ => {}
}
}
None
}
fn json_unescape(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('u') => {
let hex: String = chars.by_ref().take(4).collect();
if let Ok(cp) = u32::from_str_radix(&hex, 16)
&& let Some(c) = char::from_u32(cp)
{
out.push(c);
}
}
Some(c) => {
out.push('\\');
out.push(c);
}
None => out.push('\\'),
}
} else {
out.push(ch);
}
}
out
}
fn check_trace_schema_compat(schema_version: &str, line_num: usize) -> Result<(), TraceParseError> {
let incompatible = schema_version != SCHEMA_VERSION;
#[cfg(feature = "tracing")]
{
let span = info_span!(
"trace.compat_check",
reader_schema_version = SCHEMA_VERSION,
writer_schema_version = schema_version,
line = line_num,
compatible = !incompatible,
);
let _guard = span.enter();
if incompatible {
error!(
reader_schema_version = SCHEMA_VERSION,
writer_schema_version = schema_version,
line = line_num,
"trace schema version incompatible"
);
}
}
if incompatible {
return Err(TraceParseError {
line: line_num,
message: format!(
"unsupported schema_version: {schema_version} (reader={SCHEMA_VERSION}, migration required)"
),
});
}
Ok(())
}
fn parse_trace_line(line: &str, line_num: usize) -> Result<TraceRecord, TraceParseError> {
let err = |msg: &str| TraceParseError {
line: line_num,
message: msg.to_string(),
};
let schema_version = extract_str(line, "schema_version")
.ok_or_else(|| err("missing \"schema_version\" field"))?;
check_trace_schema_compat(schema_version, line_num)?;
let event = extract_str(line, "event").ok_or_else(|| err("missing \"event\" field"))?;
match event {
"trace_header" => {
let seed = extract_u64(line, "seed").unwrap_or(0);
let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
let profile = extract_str(line, "profile")
.map(|s| s.to_string())
.unwrap_or_else(|| "modern".to_string());
Ok(TraceRecord::Header {
seed,
cols,
rows,
profile,
})
}
"input" => {
let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
let data = extract_object(line, "data").ok_or_else(|| err("missing data object"))?;
let event = parse_event_json(data).map_err(|e| err(&e))?;
Ok(TraceRecord::Input { ts_ns, event })
}
"resize" => {
let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
let cols = extract_u16(line, "cols").ok_or_else(|| err("missing cols"))?;
let rows = extract_u16(line, "rows").ok_or_else(|| err("missing rows"))?;
Ok(TraceRecord::Resize { ts_ns, cols, rows })
}
"tick" => {
let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
Ok(TraceRecord::Tick { ts_ns })
}
"frame" => {
let frame_idx =
extract_u64(line, "frame_idx").ok_or_else(|| err("missing frame_idx"))?;
let ts_ns = extract_u64(line, "ts_ns").ok_or_else(|| err("missing ts_ns"))?;
let checksum =
extract_hex_u64(line, "frame_hash").ok_or_else(|| err("missing frame_hash"))?;
let checksum_chain = extract_hex_u64(line, "checksum_chain")
.ok_or_else(|| err("missing checksum_chain"))?;
Ok(TraceRecord::Frame {
frame_idx,
ts_ns,
checksum,
checksum_chain,
})
}
"trace_summary" => {
let total_frames =
extract_u64(line, "total_frames").ok_or_else(|| err("missing total_frames"))?;
let final_checksum_chain = extract_hex_u64(line, "final_checksum_chain")
.ok_or_else(|| err("missing final_checksum_chain"))?;
Ok(TraceRecord::Summary {
total_frames,
final_checksum_chain,
})
}
other => Err(err(&format!("unknown event type: {other}"))),
}
}
fn parse_event_json(data: &str) -> Result<Event, String> {
let kind = extract_str(data, "kind").ok_or("missing event kind")?;
match kind {
"key" => {
let code_str = extract_str(data, "code").ok_or("missing key code")?;
let code = parse_key_code(&json_unescape(code_str))?;
let mods_bits = extract_u64(data, "modifiers")
.or(extract_u64(data, "mods"))
.unwrap_or(0) as u8;
let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
let event_kind = if let Some(event_kind_str) = extract_str(data, "event_kind") {
match event_kind_str {
"press" => KeyEventKind::Press,
"repeat" => KeyEventKind::Repeat,
"release" => KeyEventKind::Release,
_ => KeyEventKind::Press,
}
} else {
let phase = extract_str(data, "phase").unwrap_or("down");
let repeat = extract_bool(data, "repeat").unwrap_or(false);
parse_key_event_kind(phase, repeat)
};
Ok(Event::Key(KeyEvent {
code,
modifiers,
kind: event_kind,
}))
}
"mouse" => {
let mouse_kind = if let Some(mouse_kind_str) = extract_str(data, "mouse_kind") {
parse_mouse_event_kind(mouse_kind_str)?
} else {
let phase = extract_str(data, "phase").ok_or("missing phase for mouse event")?;
let button = extract_u64(data, "button")
.map(|raw| {
u8::try_from(raw).map_err(|_| "mouse button out of range".to_string())
})
.transpose()?;
parse_mouse_phase_and_button(phase, button)?
};
let x = extract_u16(data, "x").unwrap_or(0);
let y = extract_u16(data, "y").unwrap_or(0);
let mods_bits = extract_u64(data, "modifiers")
.or(extract_u64(data, "mods"))
.unwrap_or(0) as u8;
let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
Ok(Event::Mouse(MouseEvent {
kind: mouse_kind,
x,
y,
modifiers,
}))
}
"wheel" => {
let x = extract_u16(data, "x").unwrap_or(0);
let y = extract_u16(data, "y").unwrap_or(0);
let dx = extract_i64(data, "dx")
.and_then(|value| i16::try_from(value).ok())
.unwrap_or(0);
let dy = extract_i64(data, "dy")
.and_then(|value| i16::try_from(value).ok())
.unwrap_or(0);
let kind = if dy < 0 {
MouseEventKind::ScrollUp
} else if dy > 0 {
MouseEventKind::ScrollDown
} else if dx < 0 {
MouseEventKind::ScrollLeft
} else if dx > 0 {
MouseEventKind::ScrollRight
} else {
return Err("wheel event must include non-zero dx or dy".to_string());
};
let mods_bits = extract_u64(data, "modifiers")
.or(extract_u64(data, "mods"))
.unwrap_or(0) as u8;
let modifiers = Modifiers::from_bits(mods_bits).unwrap_or_else(Modifiers::empty);
Ok(Event::Mouse(MouseEvent {
kind,
x,
y,
modifiers,
}))
}
"resize" => {
let width = extract_u16(data, "width").ok_or("missing width")?;
let height = extract_u16(data, "height").ok_or("missing height")?;
Ok(Event::Resize { width, height })
}
"paste" => {
let text = extract_str(data, "text")
.or(extract_str(data, "data"))
.map(json_unescape)
.unwrap_or_default();
let bracketed = extract_bool(data, "bracketed").unwrap_or(true);
Ok(Event::Paste(PasteEvent::new(text, bracketed)))
}
"ime" | "composition" => {
let phase_raw = extract_str(data, "phase").unwrap_or("update");
let phase = parse_ime_phase(phase_raw)?;
let text = extract_str(data, "text")
.or(extract_str(data, "data"))
.map(json_unescape)
.unwrap_or_default();
let ime = match phase {
ImePhase::Start => ImeEvent::start(),
ImePhase::Update => ImeEvent::update(text),
ImePhase::Commit => ImeEvent::commit(text),
ImePhase::Cancel => ImeEvent::cancel(),
};
Ok(Event::Ime(ime))
}
"focus" => {
let gained = extract_bool(data, "gained")
.or(extract_bool(data, "focused"))
.unwrap_or(true);
Ok(Event::Focus(gained))
}
"clipboard" => {
let content = extract_str(data, "content")
.map(json_unescape)
.unwrap_or_default();
let source_str = extract_str(data, "source").unwrap_or("unknown");
let source = match source_str {
"osc52" => ClipboardSource::Osc52,
_ => ClipboardSource::Unknown,
};
Ok(Event::Clipboard(ClipboardEvent::new(content, source)))
}
"tick" => Ok(Event::Tick),
other => Err(format!("unknown event kind: {other}")),
}
}
fn parse_ime_phase(phase: &str) -> Result<ImePhase, String> {
match phase {
"start" => Ok(ImePhase::Start),
"update" => Ok(ImePhase::Update),
"end" | "commit" => Ok(ImePhase::Commit),
"cancel" => Ok(ImePhase::Cancel),
other => Err(format!("unknown ime/composition phase: {other}")),
}
}
fn parse_key_code(s: &str) -> Result<KeyCode, String> {
if let Some(rest) = s.strip_prefix("char:") {
let ch = rest.chars().next().ok_or("empty char code")?;
return Ok(KeyCode::Char(ch));
}
if let Some(rest) = s.strip_prefix("f:") {
let n: u8 = rest.parse().map_err(|_| "invalid F-key number")?;
return Ok(KeyCode::F(n));
}
if let Some(n) = parse_function_key_token(s) {
return Ok(KeyCode::F(n));
}
let mut chars = s.chars();
if let Some(ch) = chars.next()
&& chars.next().is_none()
{
return Ok(KeyCode::Char(ch));
}
let normalized = s.to_ascii_lowercase();
match normalized.as_str() {
"enter" | "return" => Ok(KeyCode::Enter),
"escape" | "esc" => Ok(KeyCode::Escape),
"backspace" => Ok(KeyCode::Backspace),
"tab" => Ok(KeyCode::Tab),
"backtab" => Ok(KeyCode::BackTab),
"delete" => Ok(KeyCode::Delete),
"insert" => Ok(KeyCode::Insert),
"home" => Ok(KeyCode::Home),
"end" => Ok(KeyCode::End),
"pageup" => Ok(KeyCode::PageUp),
"pagedown" => Ok(KeyCode::PageDown),
"up" | "arrowup" => Ok(KeyCode::Up),
"down" | "arrowdown" => Ok(KeyCode::Down),
"left" | "arrowleft" => Ok(KeyCode::Left),
"right" | "arrowright" => Ok(KeyCode::Right),
"null" | "unidentified" => Ok(KeyCode::Null),
"media_play_pause" => Ok(KeyCode::MediaPlayPause),
"media_stop" => Ok(KeyCode::MediaStop),
"media_next" => Ok(KeyCode::MediaNextTrack),
"media_prev" => Ok(KeyCode::MediaPrevTrack),
other => Err(format!("unknown key code: {other}")),
}
}
fn parse_function_key_token(s: &str) -> Option<u8> {
let rest = s.strip_prefix('F').or_else(|| s.strip_prefix('f'))?;
if rest.is_empty() || !rest.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
rest.parse().ok()
}
fn parse_key_event_kind(phase: &str, repeat: bool) -> KeyEventKind {
if phase.eq_ignore_ascii_case("up") || phase.eq_ignore_ascii_case("release") {
KeyEventKind::Release
} else if repeat {
KeyEventKind::Repeat
} else {
KeyEventKind::Press
}
}
fn parse_mouse_event_kind(s: &str) -> Result<MouseEventKind, String> {
match s {
"down_left" => Ok(MouseEventKind::Down(MouseButton::Left)),
"down_right" => Ok(MouseEventKind::Down(MouseButton::Right)),
"down_middle" => Ok(MouseEventKind::Down(MouseButton::Middle)),
"up_left" => Ok(MouseEventKind::Up(MouseButton::Left)),
"up_right" => Ok(MouseEventKind::Up(MouseButton::Right)),
"up_middle" => Ok(MouseEventKind::Up(MouseButton::Middle)),
"drag_left" => Ok(MouseEventKind::Drag(MouseButton::Left)),
"drag_right" => Ok(MouseEventKind::Drag(MouseButton::Right)),
"drag_middle" => Ok(MouseEventKind::Drag(MouseButton::Middle)),
"moved" => Ok(MouseEventKind::Moved),
"scroll_up" => Ok(MouseEventKind::ScrollUp),
"scroll_down" => Ok(MouseEventKind::ScrollDown),
"scroll_left" => Ok(MouseEventKind::ScrollLeft),
"scroll_right" => Ok(MouseEventKind::ScrollRight),
other => Err(format!("unknown mouse event kind: {other}")),
}
}
fn parse_mouse_phase_and_button(phase: &str, button: Option<u8>) -> Result<MouseEventKind, String> {
match phase {
"down" => Ok(MouseEventKind::Down(parse_mouse_button(
button.ok_or("mouse down requires button")?,
)?)),
"up" => Ok(MouseEventKind::Up(parse_mouse_button(
button.ok_or("mouse up requires button")?,
)?)),
"drag" => Ok(MouseEventKind::Drag(parse_mouse_button(
button.ok_or("mouse drag requires button")?,
)?)),
"move" => Ok(MouseEventKind::Moved),
other => Err(format!("unknown mouse phase: {other}")),
}
}
fn parse_mouse_button(raw: u8) -> Result<MouseButton, String> {
match raw {
0 => Ok(MouseButton::Left),
1 => Ok(MouseButton::Middle),
2 => Ok(MouseButton::Right),
other => Err(format!("unsupported mouse button: {other}")),
}
}
pub fn gate_trace<M: ftui_runtime::program::Model>(
model: M,
trace: &SessionTrace,
) -> Result<GateReport, ReplayError> {
let result = replay(model, trace)?;
let frame_checksums: Vec<(u64, u64)> = trace
.records
.iter()
.filter_map(|r| match r {
TraceRecord::Frame {
frame_idx,
checksum,
..
} => Some((*frame_idx, *checksum)),
_ => None,
})
.collect();
let diff = result.first_mismatch.as_ref().map(|m| {
let mut event_idx: u64 = 0;
let mut last_event_desc = String::new();
let mut frame_count: u64 = 0;
for record in &trace.records {
match record {
TraceRecord::Frame { .. } => {
if frame_count == m.frame_idx {
break;
}
frame_count += 1;
}
TraceRecord::Input { event, .. } => {
last_event_desc = format!("{event:?}");
event_idx += 1;
}
TraceRecord::Resize { cols, rows, .. } => {
last_event_desc = format!("Resize({cols}x{rows})");
event_idx += 1;
}
TraceRecord::Tick { ts_ns } => {
last_event_desc = format!("Tick(ts_ns={ts_ns})");
event_idx += 1;
}
_ => {}
}
}
GateDiff {
frame_idx: m.frame_idx,
event_idx,
last_event: last_event_desc,
expected_checksum: m.expected,
actual_checksum: m.actual,
}
});
Ok(GateReport {
passed: result.ok(),
total_frames: result.total_frames,
expected_frames: frame_checksums.len() as u64,
final_checksum_chain: result.final_checksum_chain,
diff,
})
}
#[derive(Debug, Clone)]
pub struct GateReport {
pub passed: bool,
pub total_frames: u64,
pub expected_frames: u64,
pub final_checksum_chain: u64,
pub diff: Option<GateDiff>,
}
impl GateReport {
pub fn format(&self) -> String {
if self.passed {
format!(
"PASS: {}/{} frames verified, final_chain={:016x}",
self.total_frames, self.expected_frames, self.final_checksum_chain
)
} else if let Some(d) = &self.diff {
format!(
"FAIL at frame {} (after event #{}: {}): expected {:016x}, got {:016x}",
d.frame_idx, d.event_idx, d.last_event, d.expected_checksum, d.actual_checksum
)
} else {
format!(
"FAIL: {}/{} frames, unknown mismatch",
self.total_frames, self.expected_frames
)
}
}
}
#[derive(Debug, Clone)]
pub struct GateDiff {
pub frame_idx: u64,
pub event_idx: u64,
pub last_event: String,
pub expected_checksum: u64,
pub actual_checksum: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_core::event::{
KeyCode, KeyEvent, KeyEventKind, Modifiers, MouseButton, MouseEvent, MouseEventKind,
PasteEvent,
};
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_runtime::program::{Cmd, Model};
use pretty_assertions::assert_eq;
#[cfg(feature = "tracing")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "tracing")]
use tracing::Subscriber;
#[cfg(feature = "tracing")]
use tracing::field::{Field, Visit};
#[cfg(feature = "tracing")]
use tracing_subscriber::Layer;
#[cfg(feature = "tracing")]
use tracing_subscriber::filter::LevelFilter;
#[cfg(feature = "tracing")]
use tracing_subscriber::layer::{Context, SubscriberExt};
#[cfg(feature = "tracing")]
use tracing_subscriber::registry::LookupSpan;
#[cfg(feature = "tracing")]
#[derive(Default, Clone)]
struct TraceCaptureLayer {
spans: Arc<Mutex<Vec<String>>>,
events: Arc<Mutex<Vec<String>>>,
}
#[cfg(feature = "tracing")]
#[derive(Default)]
struct EventMessageVisitor {
message: Option<String>,
}
#[cfg(feature = "tracing")]
impl Visit for EventMessageVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.message = Some(value.to_string());
}
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = Some(format!("{value:?}"));
}
}
}
#[cfg(feature = "tracing")]
impl<S> Layer<S> for TraceCaptureLayer
where
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
_id: &tracing::span::Id,
_ctx: Context<'_, S>,
) {
self.spans
.lock()
.expect("span capture lock")
.push(attrs.metadata().name().to_string());
}
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
let mut visitor = EventMessageVisitor::default();
event.record(&mut visitor);
let message = visitor.message.unwrap_or_default();
self.events
.lock()
.expect("event capture lock")
.push(format!("{}:{}", event.metadata().level(), message));
}
}
struct Counter {
value: i32,
}
#[derive(Debug)]
enum CounterMsg {
Increment,
Decrement,
Reset,
Quit,
}
impl From<Event> for CounterMsg {
fn from(event: Event) -> Self {
match event {
Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
Event::Key(k) if k.code == KeyCode::Char('r') => CounterMsg::Reset,
Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
Event::Tick => CounterMsg::Increment,
_ => CounterMsg::Increment,
}
}
}
impl Model for Counter {
type Message = CounterMsg;
fn init(&mut self) -> Cmd<Self::Message> {
Cmd::none()
}
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
CounterMsg::Increment => {
self.value += 1;
Cmd::none()
}
CounterMsg::Decrement => {
self.value -= 1;
Cmd::none()
}
CounterMsg::Reset => {
self.value = 0;
Cmd::none()
}
CounterMsg::Quit => Cmd::quit(),
}
}
fn view(&self, frame: &mut Frame) {
let text = format!("Count: {}", self.value);
for (i, c) in text.chars().enumerate() {
if (i as u16) < frame.width() {
frame.buffer.set_raw(i as u16, 0, Cell::from_char(c));
}
}
}
}
fn key_event(c: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
})
}
fn parse_single_input_event(data_json: &str) -> Event {
let line = format!(
r#"{{"schema_version":"{}","event":"input","ts_ns":0,"data":{}}}"#,
SCHEMA_VERSION, data_json
);
let trace = SessionTrace::from_jsonl(&line).expect("input JSON should parse");
trace
.records
.into_iter()
.next()
.and_then(|record| match record {
TraceRecord::Input { event, .. } => Some(event),
_ => None,
})
.expect("expected single input record")
}
fn new_counter(value: i32) -> Counter {
Counter { value }
}
#[test]
fn fnv1a64_pair_is_deterministic() {
let a = fnv1a64_pair(0, 1234);
let b = fnv1a64_pair(0, 1234);
assert_eq!(a, b);
}
#[test]
fn fnv1a64_pair_differs_for_different_input() {
assert_ne!(fnv1a64_pair(0, 1), fnv1a64_pair(0, 2));
assert_ne!(fnv1a64_pair(1, 0), fnv1a64_pair(2, 0));
}
#[test]
fn recorder_produces_header_and_summary() {
let mut rec = SessionRecorder::new(new_counter(0), 80, 24, 42);
rec.init().unwrap();
let trace = rec.finish();
assert!(trace.records.len() >= 3);
assert!(matches!(
&trace.records[0],
TraceRecord::Header {
seed: 42,
cols: 80,
rows: 24,
..
}
));
assert!(matches!(
trace.records.last().unwrap(),
TraceRecord::Summary {
total_frames: 1,
..
}
));
}
#[test]
fn recorder_captures_init_frame() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
let trace = rec.finish();
let frames: Vec<_> = trace
.records
.iter()
.filter(|r| matches!(r, TraceRecord::Frame { .. }))
.collect();
assert_eq!(frames.len(), 1);
if let TraceRecord::Frame {
frame_idx,
checksum,
..
} = &frames[0]
{
assert_eq!(*frame_idx, 0);
assert_ne!(*checksum, 0); }
}
#[test]
fn record_replay_identical_checksums() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('+'));
rec.push_event(3_000_000, key_event('-'));
rec.step().unwrap();
rec.push_event(16_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
assert_eq!(trace.frame_count(), 3);
let result = replay(new_counter(0), &trace).unwrap();
assert!(result.ok(), "replay mismatch: {:?}", result.first_mismatch);
assert_eq!(result.total_frames, 3);
assert_eq!(
result.final_checksum_chain,
trace.final_checksum_chain().unwrap()
);
}
#[test]
fn replay_detects_different_initial_state() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
let trace = rec.finish();
let result = replay(new_counter(5), &trace).unwrap();
assert!(!result.ok());
assert_eq!(result.first_mismatch.as_ref().unwrap().frame_idx, 0);
}
#[test]
fn replay_detects_divergence_after_events() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
let result = replay(new_counter(1), &trace).unwrap();
assert!(!result.ok());
}
#[test]
fn resize_is_recorded_and_replayed() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.resize(5_000_000, 40, 2);
rec.step().unwrap();
let trace = rec.finish();
assert!(trace.records.iter().any(|r| matches!(
r,
TraceRecord::Resize {
cols: 40,
rows: 2,
..
}
)));
let result = replay(new_counter(0), &trace).unwrap();
assert!(
result.ok(),
"resize replay mismatch: {:?}",
result.first_mismatch
);
}
#[test]
fn multi_step_record_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
for i in 0..5 {
rec.push_event(i * 16_000_000, key_event('+'));
rec.step().unwrap();
}
let trace = rec.finish();
assert_eq!(trace.frame_count(), 6);
let result = replay(new_counter(0), &trace).unwrap();
assert!(
result.ok(),
"multi-step mismatch: {:?}",
result.first_mismatch
);
assert_eq!(result.total_frames, 6);
}
#[test]
fn quit_stops_recording() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('q'));
let result = rec.step().unwrap();
assert!(!result.running);
let trace = rec.finish();
assert_eq!(trace.frame_count(), 1);
}
#[test]
fn empty_session_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
let trace = rec.finish();
let result = replay(new_counter(0), &trace).unwrap();
assert!(result.ok());
assert_eq!(result.total_frames, 1); }
#[test]
fn session_trace_frame_count() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
assert_eq!(trace.frame_count(), 2);
}
#[test]
fn session_trace_final_checksum_chain() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
let trace = rec.finish();
assert!(trace.final_checksum_chain().is_some());
assert_ne!(trace.final_checksum_chain().unwrap(), 0);
}
#[test]
fn replay_missing_header_returns_error() {
let trace = SessionTrace { records: vec![] };
let result = replay(new_counter(0), &trace);
assert!(matches!(result, Err(ReplayError::MissingHeader)));
}
#[test]
fn replay_non_header_first_returns_error() {
let trace = SessionTrace {
records: vec![TraceRecord::Tick { ts_ns: 0 }],
};
let result = replay(new_counter(0), &trace);
assert!(matches!(result, Err(ReplayError::MissingHeader)));
}
#[test]
fn trace_validate_missing_summary_returns_typed_error() {
let trace = SessionTrace {
records: vec![TraceRecord::Header {
seed: 0,
cols: 80,
rows: 24,
profile: "modern".to_string(),
}],
};
let result = trace.validate();
assert_eq!(result, Err(TraceValidationError::MissingSummary));
}
#[test]
fn trace_validate_summary_frame_count_mismatch_returns_typed_error() {
let trace = SessionTrace {
records: vec![
TraceRecord::Header {
seed: 0,
cols: 80,
rows: 24,
profile: "modern".to_string(),
},
TraceRecord::Frame {
frame_idx: 0,
ts_ns: 0,
checksum: 0x1,
checksum_chain: 0x10,
},
TraceRecord::Summary {
total_frames: 2,
final_checksum_chain: 0x10,
},
],
};
let result = trace.validate();
assert_eq!(
result,
Err(TraceValidationError::SummaryFrameCountMismatch {
expected: 1,
actual: 2,
})
);
}
#[test]
fn trace_validate_frame_index_gap_returns_typed_error() {
let trace = SessionTrace {
records: vec![
TraceRecord::Header {
seed: 0,
cols: 80,
rows: 24,
profile: "modern".to_string(),
},
TraceRecord::Frame {
frame_idx: 1,
ts_ns: 0,
checksum: 0x1,
checksum_chain: 0x10,
},
TraceRecord::Summary {
total_frames: 1,
final_checksum_chain: 0x10,
},
],
};
let result = trace.validate();
assert_eq!(
result,
Err(TraceValidationError::FrameIndexMismatch {
expected: 0,
actual: 1,
})
);
}
#[test]
fn trace_validate_timestamp_regression_returns_typed_error() {
let trace = SessionTrace {
records: vec![
TraceRecord::Header {
seed: 0,
cols: 80,
rows: 24,
profile: "modern".to_string(),
},
TraceRecord::Tick { ts_ns: 20 },
TraceRecord::Tick { ts_ns: 10 },
TraceRecord::Summary {
total_frames: 0,
final_checksum_chain: 0,
},
],
};
let result = trace.validate();
assert_eq!(
result,
Err(TraceValidationError::TimestampRegression {
previous: 20,
current: 10,
record_index: 2,
})
);
}
#[test]
fn replay_validates_trace_before_execution() {
let trace = SessionTrace {
records: vec![TraceRecord::Header {
seed: 0,
cols: 80,
rows: 24,
profile: "modern".to_string(),
}],
};
let result = replay(new_counter(0), &trace);
assert_eq!(
result,
Err(ReplayError::InvalidTrace(
TraceValidationError::MissingSummary
))
);
}
#[test]
fn same_inputs_produce_same_trace_checksums() {
fn record_session() -> SessionTrace {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('+'));
rec.push_event(3_000_000, key_event('-'));
rec.step().unwrap();
rec.push_event(16_000_000, key_event('+'));
rec.step().unwrap();
rec.finish()
}
let t1 = record_session();
let t2 = record_session();
let t3 = record_session();
let checksums = |t: &SessionTrace| -> Vec<u64> {
t.records
.iter()
.filter_map(|r| match r {
TraceRecord::Frame { checksum, .. } => Some(*checksum),
_ => None,
})
.collect()
};
assert_eq!(checksums(&t1), checksums(&t2));
assert_eq!(checksums(&t2), checksums(&t3));
assert_eq!(t1.final_checksum_chain(), t2.final_checksum_chain());
}
#[test]
fn mouse_event_record_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
let mouse = Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
x: 5,
y: 0,
modifiers: Modifiers::empty(),
});
rec.push_event(1_000_000, mouse);
rec.step().unwrap();
let trace = rec.finish();
let result = replay(new_counter(0), &trace).unwrap();
assert!(result.ok());
}
#[test]
fn paste_event_record_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
let paste = Event::Paste(PasteEvent::bracketed("hello"));
rec.push_event(1_000_000, paste);
rec.step().unwrap();
let trace = rec.finish();
let result = replay(new_counter(0), &trace).unwrap();
assert!(result.ok());
}
#[test]
fn focus_event_record_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, Event::Focus(true));
rec.push_event(2_000_000, Event::Focus(false));
rec.step().unwrap();
let trace = rec.finish();
let result = replay(new_counter(0), &trace).unwrap();
assert!(result.ok());
}
#[test]
fn ime_event_record_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, Event::Ime(ImeEvent::start()));
rec.push_event(2_000_000, Event::Ime(ImeEvent::update("你")));
rec.push_event(3_000_000, Event::Ime(ImeEvent::commit("你好")));
rec.step().unwrap();
let trace = rec.finish();
let result = replay(new_counter(0), &trace).unwrap();
assert!(result.ok());
}
#[test]
fn checksum_chain_is_cumulative() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.step().unwrap();
rec.push_event(2_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
let frame_records: Vec<_> = trace
.records
.iter()
.filter_map(|r| match r {
TraceRecord::Frame {
checksum,
checksum_chain,
..
} => Some((*checksum, *checksum_chain)),
_ => None,
})
.collect();
assert_eq!(frame_records.len(), 3);
let (c0, chain0) = frame_records[0];
assert_eq!(chain0, fnv1a64_pair(0, c0));
let (c1, chain1) = frame_records[1];
assert_eq!(chain1, fnv1a64_pair(chain0, c1));
let (c2, chain2) = frame_records[2];
assert_eq!(chain2, fnv1a64_pair(chain1, c2));
assert_eq!(trace.final_checksum_chain(), Some(chain2));
}
#[test]
fn recorder_exposes_program() {
let mut rec = SessionRecorder::new(new_counter(42), 20, 1, 0);
rec.init().unwrap();
assert_eq!(rec.program().model().value, 42);
}
#[test]
fn replay_result_ok_when_no_mismatch() {
let r = ReplayResult {
total_frames: 5,
final_checksum_chain: 123,
first_mismatch: None,
};
assert!(r.ok());
}
#[test]
fn replay_result_not_ok_when_mismatch() {
let r = ReplayResult {
total_frames: 5,
final_checksum_chain: 123,
first_mismatch: Some(ReplayMismatch {
frame_idx: 2,
expected: 100,
actual: 200,
}),
};
assert!(!r.ok());
}
#[test]
fn replay_error_display() {
assert_eq!(
ReplayError::MissingHeader.to_string(),
"trace missing header record"
);
let invalid = ReplayError::InvalidTrace(TraceValidationError::MissingSummary);
assert_eq!(
invalid.to_string(),
"invalid trace: trace is missing summary"
);
let be = ReplayError::Backend(WebBackendError::Unsupported("test"));
assert!(be.to_string().contains("test"));
}
#[test]
fn trace_record_header_to_jsonl() {
let r = TraceRecord::Header {
seed: 42,
cols: 80,
rows: 24,
profile: "modern".to_string(),
};
let line = r.to_jsonl();
assert!(line.contains("\"event\":\"trace_header\""));
assert!(line.contains("\"schema_version\":\"golden-trace-v1\""));
assert!(line.contains("\"seed\":42"));
assert!(line.contains("\"cols\":80"));
assert!(line.contains("\"rows\":24"));
assert!(line.contains("\"profile\":\"modern\""));
}
#[test]
fn trace_record_input_key_to_jsonl() {
let r = TraceRecord::Input {
ts_ns: 1_000_000,
event: key_event('+'),
};
let line = r.to_jsonl();
assert!(line.contains("\"event\":\"input\""));
assert!(line.contains("\"ts_ns\":1000000"));
assert!(line.contains("\"kind\":\"key\""));
assert!(line.contains("\"code\":\"char:+\""));
}
#[test]
fn trace_record_resize_to_jsonl() {
let r = TraceRecord::Resize {
ts_ns: 5_000_000,
cols: 120,
rows: 40,
};
let line = r.to_jsonl();
assert!(line.contains("\"event\":\"resize\""));
assert!(line.contains("\"cols\":120"));
assert!(line.contains("\"rows\":40"));
}
#[test]
fn trace_record_frame_to_jsonl() {
let r = TraceRecord::Frame {
frame_idx: 3,
ts_ns: 48_000_000,
checksum: 0xDEADBEEF,
checksum_chain: 0xCAFEBABE,
};
let line = r.to_jsonl();
assert!(line.contains("\"event\":\"frame\""));
assert!(line.contains("\"frame_idx\":3"));
assert!(line.contains("\"frame_hash\":\"00000000deadbeef\""));
assert!(line.contains("\"checksum_chain\":\"00000000cafebabe\""));
}
#[test]
fn trace_record_summary_to_jsonl() {
let r = TraceRecord::Summary {
total_frames: 10,
final_checksum_chain: 0x1234567890ABCDEF,
};
let line = r.to_jsonl();
assert!(line.contains("\"event\":\"trace_summary\""));
assert!(line.contains("\"total_frames\":10"));
assert!(line.contains("\"final_checksum_chain\":\"1234567890abcdef\""));
}
#[test]
fn jsonl_round_trip_full_session() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 42);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
let jsonl = trace.to_jsonl();
assert!(!jsonl.is_empty());
let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
assert_eq!(parsed.records.len(), trace.records.len());
assert_eq!(parsed.frame_count(), trace.frame_count());
assert_eq!(parsed.final_checksum_chain(), trace.final_checksum_chain());
}
#[test]
fn jsonl_round_trip_preserves_events() {
let events = vec![
key_event('+'),
key_event('-'),
Event::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: Modifiers::CTRL | Modifiers::SHIFT,
kind: KeyEventKind::Press,
}),
Event::Key(KeyEvent {
code: KeyCode::F(12),
modifiers: Modifiers::ALT,
kind: KeyEventKind::Repeat,
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
x: 10,
y: 5,
modifiers: Modifiers::empty(),
}),
Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
x: 0,
y: 0,
modifiers: Modifiers::CTRL,
}),
Event::Paste(PasteEvent::bracketed("hello world")),
Event::Focus(true),
Event::Focus(false),
Event::Tick,
];
for (i, event) in events.iter().enumerate() {
let record = TraceRecord::Input {
ts_ns: i as u64 * 1_000_000,
event: event.clone(),
};
let jsonl = record.to_jsonl();
let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
let parsed_record = &parsed.records[0];
let TraceRecord::Input {
event: parsed_event,
..
} = parsed_record
else {
unreachable!("expected Input record for event {i}");
};
assert_eq!(parsed_event, event, "event {i} round-trip failed: {jsonl}");
}
}
#[test]
fn jsonl_round_trip_with_resize() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.resize(5_000_000, 40, 2);
rec.step().unwrap();
let trace = rec.finish();
let jsonl = trace.to_jsonl();
let parsed = SessionTrace::from_jsonl(&jsonl).unwrap();
let result = replay(new_counter(0), &parsed).unwrap();
assert!(
result.ok(),
"parsed trace replay failed: {:?}",
result.first_mismatch
);
}
#[test]
fn from_jsonl_empty_is_ok() {
let trace = SessionTrace::from_jsonl("").unwrap();
assert!(trace.records.is_empty());
}
#[test]
fn from_jsonl_unknown_event_fails() {
let line = r#"{"schema_version":"golden-trace-v1","event":"unknown_type","ts_ns":0}"#;
let result = SessionTrace::from_jsonl(line);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("unknown event type"));
}
#[test]
fn from_jsonl_missing_event_field_fails() {
let line = r#"{"schema_version":"golden-trace-v1","ts_ns":0}"#;
let result = SessionTrace::from_jsonl(line);
assert!(result.is_err());
}
#[test]
fn from_jsonl_missing_schema_version_fails() {
let line = r#"{"event":"tick","ts_ns":0}"#;
let result = SessionTrace::from_jsonl(line);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("schema_version"));
}
#[test]
fn from_jsonl_schema_matrix_current_writer_version_passes() {
let line = format!(
r#"{{"schema_version":"{}","event":"tick","ts_ns":0}}"#,
SCHEMA_VERSION
);
let trace = SessionTrace::from_jsonl(&line).expect("matching schema should parse");
assert_eq!(trace.records, vec![TraceRecord::Tick { ts_ns: 0 }]);
}
#[test]
fn from_jsonl_schema_matrix_newer_writer_version_fails_with_migration_error() {
let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
let result = SessionTrace::from_jsonl(line);
assert!(result.is_err());
let message = result.unwrap_err().message;
assert!(message.contains("unsupported schema_version"));
assert!(message.contains("migration required"));
}
#[cfg(feature = "tracing")]
#[test]
fn schema_incompatibility_emits_compat_span_and_error_log() {
let capture = TraceCaptureLayer::default();
let subscriber =
tracing_subscriber::registry().with(capture.clone().with_filter(LevelFilter::TRACE));
let _guard = tracing::subscriber::set_default(subscriber);
let line = r#"{"schema_version":"golden-trace-v2","event":"tick","ts_ns":0}"#;
let err = SessionTrace::from_jsonl(line).expect_err("newer schema should fail");
assert!(err.message.contains("migration required"));
let spans = capture.spans.lock().expect("span capture lock");
assert!(
spans.iter().any(|name| name == "trace.compat_check"),
"expected trace.compat_check span, got {spans:?}"
);
drop(spans);
let events = capture.events.lock().expect("event capture lock");
assert!(
events
.iter()
.any(|event| event.contains("ERROR:trace schema version incompatible")),
"expected incompatible schema ERROR log, got {events:?}"
);
}
#[test]
fn from_jsonl_validated_surfaces_validation_error_type() {
let jsonl = TraceRecord::Header {
seed: 0,
cols: 80,
rows: 24,
profile: "modern".to_string(),
}
.to_jsonl();
let result = SessionTrace::from_jsonl_validated(&jsonl);
assert!(matches!(
result,
Err(TraceLoadError::Validation(
TraceValidationError::MissingSummary
))
));
}
#[test]
fn json_escape_round_trip() {
let cases = [
"hello",
"with\"quotes",
"back\\slash",
"line\nbreak",
"tab\there",
];
for input in cases {
let escaped = json_escape(input);
let unescaped = json_unescape(&escaped);
assert_eq!(unescaped, input, "round-trip failed for: {input:?}");
}
}
#[test]
fn extract_str_basic() {
let json = r#"{"name":"alice","age":30}"#;
assert_eq!(extract_str(json, "name"), Some("alice"));
}
#[test]
fn extract_u64_basic() {
let json = r#"{"count":42,"name":"test"}"#;
assert_eq!(extract_u64(json, "count"), Some(42));
}
#[test]
fn extract_i64_basic() {
let json = r#"{"dx":-12,"dy":7}"#;
assert_eq!(extract_i64(json, "dx"), Some(-12));
assert_eq!(extract_i64(json, "dy"), Some(7));
}
#[test]
fn extract_bool_basic() {
let json = r#"{"enabled":true,"disabled":false}"#;
assert_eq!(extract_bool(json, "enabled"), Some(true));
assert_eq!(extract_bool(json, "disabled"), Some(false));
}
#[test]
fn extract_hex_u64_basic() {
let json = r#"{"hash":"00000000deadbeef"}"#;
assert_eq!(extract_hex_u64(json, "hash"), Some(0xDEADBEEF));
}
#[test]
fn from_jsonl_parses_frankenterm_key_schema() {
let down = parse_single_input_event(
r#"{"kind":"key","phase":"down","code":"F12","mods":5,"repeat":false}"#,
);
assert_eq!(
down,
Event::Key(KeyEvent {
code: KeyCode::F(12),
modifiers: Modifiers::SHIFT | Modifiers::CTRL,
kind: KeyEventKind::Press,
})
);
let repeat = parse_single_input_event(
r#"{"kind":"key","phase":"down","code":"a","mods":0,"repeat":true}"#,
);
assert_eq!(
repeat,
Event::Key(KeyEvent {
code: KeyCode::Char('a'),
modifiers: Modifiers::empty(),
kind: KeyEventKind::Repeat,
})
);
let release = parse_single_input_event(
r#"{"kind":"key","phase":"up","code":"Enter","mods":0,"repeat":false}"#,
);
assert_eq!(
release,
Event::Key(KeyEvent {
code: KeyCode::Enter,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Release,
})
);
}
#[test]
fn key_event_json_round_trip_unescapes_code() {
let quote_key = Event::Key(KeyEvent {
code: KeyCode::Char('"'),
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
});
let quote_json = event_to_json("e_key);
let parsed_quote = parse_event_json("e_json).expect("quote key should parse");
assert_eq!(parsed_quote, quote_key);
let slash_key = Event::Key(KeyEvent {
code: KeyCode::Char('\\'),
modifiers: Modifiers::SHIFT,
kind: KeyEventKind::Press,
});
let slash_json = event_to_json(&slash_key);
let parsed_slash = parse_event_json(&slash_json).expect("slash key should parse");
assert_eq!(parsed_slash, slash_key);
}
#[test]
fn from_jsonl_parses_frankenterm_mouse_and_wheel_schema() {
let mouse = parse_single_input_event(
r#"{"kind":"mouse","phase":"drag","button":2,"x":7,"y":9,"mods":3}"#,
);
assert_eq!(
mouse,
Event::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Right),
x: 7,
y: 9,
modifiers: Modifiers::SHIFT | Modifiers::ALT,
})
);
let wheel =
parse_single_input_event(r#"{"kind":"wheel","x":4,"y":6,"dx":0,"dy":-2,"mods":4}"#);
assert_eq!(
wheel,
Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
x: 4,
y: 6,
modifiers: Modifiers::CTRL,
})
);
}
#[test]
fn from_jsonl_parses_frankenterm_paste_focus_and_composition_aliases() {
let paste = parse_single_input_event(r#"{"kind":"paste","data":"hello\nworld"}"#);
assert_eq!(paste, Event::Paste(PasteEvent::new("hello\nworld", true)));
let focus = parse_single_input_event(r#"{"kind":"focus","focused":false}"#);
assert_eq!(focus, Event::Focus(false));
let composition_update =
parse_single_input_event(r#"{"kind":"composition","phase":"update","data":"你"}"#);
assert_eq!(
composition_update,
Event::Ime(ImeEvent::new(ImePhase::Update, "你"))
);
let composition_end =
parse_single_input_event(r#"{"kind":"composition","phase":"end","data":"你好"}"#);
assert_eq!(
composition_end,
Event::Ime(ImeEvent::new(ImePhase::Commit, "你好"))
);
}
#[test]
fn gate_trace_passes_on_correct_replay() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
let report = gate_trace(new_counter(0), &trace).unwrap();
assert!(report.passed);
assert_eq!(report.total_frames, 2);
assert!(report.diff.is_none());
assert!(report.format().starts_with("PASS"));
}
#[test]
fn gate_trace_fails_with_actionable_diff() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('+'));
rec.step().unwrap();
let trace = rec.finish();
let report = gate_trace(new_counter(5), &trace).unwrap();
assert!(!report.passed);
assert!(report.diff.is_some());
let diff = report.diff.as_ref().unwrap();
assert_eq!(diff.frame_idx, 0);
let formatted = report.format();
assert!(formatted.starts_with("FAIL"));
assert!(formatted.contains("frame 0"));
}
#[test]
fn gate_trace_diff_has_event_context() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
rec.push_event(1_000_000, key_event('+'));
rec.push_event(2_000_000, key_event('+'));
rec.step().unwrap();
rec.push_event(3_000_000, key_event('-'));
rec.step().unwrap();
let trace = rec.finish();
let mut tampered = trace.clone();
for record in &mut tampered.records {
if let TraceRecord::Frame {
frame_idx,
checksum,
..
} = record
&& *frame_idx == 2
{
*checksum = 0xBAD;
}
}
let report = gate_trace(new_counter(0), &tampered).unwrap();
assert!(!report.passed);
let diff = report.diff.unwrap();
assert_eq!(diff.frame_idx, 2);
assert!(diff.event_idx > 0); }
#[test]
fn jsonl_serialize_parse_replay_round_trip() {
let mut rec = SessionRecorder::new(new_counter(0), 20, 1, 0);
rec.init().unwrap();
for i in 0..3 {
rec.push_event(i * 16_000_000, key_event('+'));
rec.step().unwrap();
}
let original_trace = rec.finish();
let jsonl = original_trace.to_jsonl();
let parsed_trace = SessionTrace::from_jsonl(&jsonl).unwrap();
let result = replay(new_counter(0), &parsed_trace).unwrap();
assert!(
result.ok(),
"JSONL round-trip replay failed: {:?}",
result.first_mismatch
);
assert_eq!(result.total_frames, original_trace.frame_count());
assert_eq!(
result.final_checksum_chain,
original_trace.final_checksum_chain().unwrap()
);
}
#[test]
fn trace_parse_error_display() {
let e = TraceParseError {
line: 5,
message: "bad field".to_string(),
};
assert_eq!(e.to_string(), "line 5: bad field");
}
}