lesavka/client/src/bin/lesavka-sync-analyze.rs

732 lines
24 KiB
Rust
Raw Normal View History

#[cfg(any(not(coverage), test))]
use anyhow::{Context, Result, bail};
#[cfg(not(coverage))]
use serde::Serialize;
#[cfg(not(coverage))]
use std::collections::BTreeSet;
2026-04-30 22:23:29 -03:00
#[cfg(any(not(coverage), test))]
use std::path::PathBuf;
#[cfg(not(coverage))]
use lesavka_client::sync_probe::analyze::{
2026-04-30 22:23:29 -03:00
SyncAnalysisOptions, SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation,
analyze_capture,
};
#[cfg(not(coverage))]
#[derive(Serialize)]
struct SyncAnalyzeOutput<'a> {
#[serde(flatten)]
report: &'a SyncAnalysisReport,
#[serde(skip_serializing_if = "Option::is_none")]
signature_coverage: Option<SignatureCoverage>,
calibration: SyncCalibrationRecommendation,
2026-04-30 22:23:29 -03:00
verdict: SyncAnalysisVerdict,
}
#[cfg(not(coverage))]
#[derive(Serialize)]
struct SignatureCoverage {
expected_event_count: usize,
expected_codes: Vec<u32>,
paired_event_count: usize,
paired_server_event_ids: Vec<usize>,
paired_codes: Vec<u32>,
missing_event_ids: Vec<usize>,
missing_codes: Vec<u32>,
unknown_pair_identity_count: usize,
}
#[cfg(not(coverage))]
fn main() -> Result<()> {
2026-04-30 22:23:29 -03:00
let args = parse_args(std::env::args().skip(1))?;
let report = analyze_capture(&args.capture_path, &args.options)
2026-04-30 22:23:29 -03:00
.with_context(|| format!("analyzing sync capture {}", args.capture_path.display()))?;
let calibration = report.calibration_recommendation();
2026-04-30 22:23:29 -03:00
let verdict = report.verdict();
let signature_coverage = signature_coverage(&args.options.event_width_codes, &report);
let human_report = format_human_report(
&args.capture_path,
&report,
signature_coverage.as_ref(),
&calibration,
&verdict,
);
2026-04-30 22:23:29 -03:00
let output = SyncAnalyzeOutput {
report: &report,
signature_coverage,
2026-04-30 22:23:29 -03:00
calibration,
verdict,
};
if let Some(report_dir) = &args.report_dir {
write_report_dir(report_dir, &human_report, &output)?;
}
2026-04-30 22:23:29 -03:00
if args.emit_json {
println!(
"{}",
serde_json::to_string_pretty(&output).context("serializing JSON report")?
);
} else {
2026-04-30 22:23:29 -03:00
print!("{human_report}");
}
Ok(())
}
#[cfg(any(not(coverage), test))]
#[derive(Debug, PartialEq)]
2026-04-30 22:23:29 -03:00
struct AnalyzeArgs {
capture_path: PathBuf,
emit_json: bool,
report_dir: Option<PathBuf>,
options: SyncAnalysisOptions,
2026-04-30 22:23:29 -03:00
}
#[cfg(any(not(coverage), test))]
fn parse_args<I, S>(args: I) -> Result<AnalyzeArgs>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
println!(
"Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]"
);
std::process::exit(0);
}
let mut emit_json = false;
2026-04-30 22:23:29 -03:00
let mut report_dir = None::<PathBuf>;
let mut capture_path = None::<PathBuf>;
let mut options = SyncAnalysisOptions::default();
2026-04-30 22:23:29 -03:00
let mut iter = args.into_iter();
while let Some(arg) = iter.next() {
if arg == "--json" {
emit_json = true;
continue;
}
2026-04-30 22:23:29 -03:00
if arg == "--report-dir" {
let Some(dir) = iter.next() else {
bail!("--report-dir requires a directory");
};
report_dir = Some(PathBuf::from(dir));
continue;
}
if let Some(dir) = arg.strip_prefix("--report-dir=") {
if dir.is_empty() {
bail!("--report-dir requires a directory");
}
report_dir = Some(PathBuf::from(dir));
continue;
}
if arg == "--event-width-codes" {
let Some(raw_codes) = iter.next() else {
bail!("--event-width-codes requires a comma-separated list");
};
options.event_width_codes = parse_event_width_codes(&raw_codes)?;
continue;
}
if let Some(raw_codes) = arg.strip_prefix("--event-width-codes=") {
if raw_codes.is_empty() {
bail!("--event-width-codes requires a comma-separated list");
}
options.event_width_codes = parse_event_width_codes(raw_codes)?;
continue;
}
if arg == "--analysis-window-s" {
let Some(raw_window) = iter.next() else {
bail!("--analysis-window-s requires START:END seconds");
};
parse_analysis_window(&raw_window, &mut options)?;
continue;
}
if let Some(raw_window) = arg.strip_prefix("--analysis-window-s=") {
if raw_window.is_empty() {
bail!("--analysis-window-s requires START:END seconds");
}
parse_analysis_window(raw_window, &mut options)?;
continue;
}
if arg == "--analysis-start-s" {
let Some(raw_start) = iter.next() else {
bail!("--analysis-start-s requires seconds");
};
options.analysis_start_s = Some(parse_analysis_seconds(&raw_start, "analysis start")?);
continue;
}
if let Some(raw_start) = arg.strip_prefix("--analysis-start-s=") {
if raw_start.is_empty() {
bail!("--analysis-start-s requires seconds");
}
options.analysis_start_s = Some(parse_analysis_seconds(raw_start, "analysis start")?);
continue;
}
if arg == "--analysis-end-s" {
let Some(raw_end) = iter.next() else {
bail!("--analysis-end-s requires seconds");
};
options.analysis_end_s = Some(parse_analysis_seconds(&raw_end, "analysis end")?);
continue;
}
if let Some(raw_end) = arg.strip_prefix("--analysis-end-s=") {
if raw_end.is_empty() {
bail!("--analysis-end-s requires seconds");
}
options.analysis_end_s = Some(parse_analysis_seconds(raw_end, "analysis end")?);
continue;
}
if capture_path.is_some() {
bail!("unexpected extra argument `{arg}`");
}
2026-04-30 22:23:29 -03:00
capture_path = Some(PathBuf::from(arg));
}
let capture_path = capture_path.context("capture path is required")?;
2026-04-30 22:23:29 -03:00
Ok(AnalyzeArgs {
capture_path,
emit_json,
report_dir,
options,
2026-04-30 22:23:29 -03:00
})
}
#[cfg(any(not(coverage), test))]
fn parse_event_width_codes(raw: &str) -> Result<Vec<u32>> {
let codes = raw
.split(',')
.filter_map(|part| {
let trimmed = part.trim();
(!trimmed.is_empty()).then_some(trimmed)
})
.map(|part| {
let code = part
.parse::<u32>()
.with_context(|| format!("parsing event width code `{part}`"))?;
if code == 0 {
bail!("event width codes must be positive");
}
Ok(code)
})
.collect::<Result<Vec<_>>>()?;
if codes.is_empty() {
bail!("event width code list must not be empty");
}
Ok(codes)
}
#[cfg(any(not(coverage), test))]
fn parse_analysis_window(raw: &str, options: &mut SyncAnalysisOptions) -> Result<()> {
let Some((start, end)) = raw.split_once(':') else {
bail!("--analysis-window-s requires START:END seconds");
};
options.analysis_start_s = if start.trim().is_empty() {
None
} else {
Some(parse_analysis_seconds(start, "analysis start")?)
};
options.analysis_end_s = if end.trim().is_empty() {
None
} else {
Some(parse_analysis_seconds(end, "analysis end")?)
};
Ok(())
}
#[cfg(any(not(coverage), test))]
fn parse_analysis_seconds(raw: &str, label: &str) -> Result<f64> {
let value = raw
.trim()
.parse::<f64>()
.with_context(|| format!("parsing {label} `{raw}`"))?;
if !value.is_finite() || value < 0.0 {
bail!("{label} must be a finite non-negative timestamp");
}
Ok(value)
}
2026-04-30 22:23:29 -03:00
#[cfg(not(coverage))]
fn format_human_report(
capture_path: &std::path::Path,
report: &SyncAnalysisReport,
signature_coverage: Option<&SignatureCoverage>,
2026-04-30 22:23:29 -03:00
calibration: &SyncCalibrationRecommendation,
verdict: &SyncAnalysisVerdict,
) -> String {
let first_paired_video = report
.paired_events
.first()
.map(|event| event.video_time_s)
.unwrap_or(0.0);
let first_paired_audio = report
.paired_events
.first()
.map(|event| event.audio_time_s)
.unwrap_or(0.0);
let unpaired_video = format_onset_list(&unpaired_video_onsets(report));
let unpaired_audio = format_onset_list(&unpaired_audio_onsets(report));
let raw_activity_handling = if report.raw_activity_start_is_verdict_relevant() {
"used as verdict evidence"
} else {
"reported only; ignored for verdict/calibration because it disagrees with paired pulses"
};
let signature_coverage = format_signature_coverage(signature_coverage);
2026-04-30 22:23:29 -03:00
format!(
"\
A/V sync report for {capture}
- verdict: {status} ({passed})
- verdict reason: {reason}
- evidence mode: {evidence_mode}
2026-04-30 22:23:29 -03:00
- p95 abs skew: {p95:.1} ms
- video onsets: {video_events}
- audio onsets: {audio_events}
- paired pulses: {paired_events}
- activity start delta: {activity_start_delta:+.1} ms (audio after video is positive)
2026-05-01 12:03:07 -03:00
- raw first video activity: {raw_video:.3} s
- raw first audio activity: {raw_audio:.3} s
- raw-vs-paired disagreement: {raw_pair_disagreement:.1} ms
- raw activity handling: {raw_activity_handling}
2026-05-01 12:03:07 -03:00
- paired window first video/audio: {paired_video:.3} s / {paired_audio:.3} s
- unpaired video onsets: {unpaired_video}
- unpaired audio onsets: {unpaired_audio}
{signature_coverage}\
2026-04-30 22:23:29 -03:00
- first skew: {first_skew:+.1} ms (audio after video is positive)
- last skew: {last_skew:+.1} ms
- mean skew: {mean_skew:+.1} ms
- median skew: {median_skew:+.1} ms
- max abs skew: {max_abs:.1} ms
- drift: {drift:+.1} ms
- calibration ready: {cal_ready}
- recommended audio offset adjust: {audio_adjust:+} us
- alternative video offset adjust: {video_adjust:+} us
- calibration note: {cal_note}
",
capture = capture_path.display(),
status = verdict.status,
passed = if verdict.passed { "pass" } else { "fail" },
reason = verdict.reason,
evidence_mode = if report.coded_events {
"coded pulses"
} else {
"cadence/brightness pulses"
},
2026-04-30 22:23:29 -03:00
p95 = verdict.p95_abs_skew_ms,
video_events = report.video_event_count,
audio_events = report.audio_event_count,
paired_events = report.paired_event_count,
activity_start_delta = report.activity_start_delta_ms,
2026-05-01 12:03:07 -03:00
raw_video = report.raw_first_video_activity_s,
raw_audio = report.raw_first_audio_activity_s,
raw_pair_disagreement = report.activity_start_pair_disagreement_ms().abs(),
raw_activity_handling = raw_activity_handling,
paired_video = first_paired_video,
paired_audio = first_paired_audio,
unpaired_video = unpaired_video,
unpaired_audio = unpaired_audio,
signature_coverage = signature_coverage,
2026-04-30 22:23:29 -03:00
first_skew = report.first_skew_ms,
last_skew = report.last_skew_ms,
mean_skew = report.mean_skew_ms,
median_skew = report.median_skew_ms,
max_abs = report.max_abs_skew_ms,
drift = report.drift_ms,
cal_ready = calibration.ready,
audio_adjust = calibration.recommended_audio_offset_adjust_us,
video_adjust = calibration.recommended_video_offset_adjust_us,
cal_note = calibration.note,
)
}
#[cfg(not(coverage))]
fn signature_coverage(
expected_codes: &[u32],
report: &SyncAnalysisReport,
) -> Option<SignatureCoverage> {
if expected_codes.is_empty() {
return None;
}
let paired_server_event_ids = report
.paired_events
.iter()
.filter_map(|event| event.server_event_id)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let paired_codes = paired_server_event_ids
.iter()
.filter_map(|index| expected_codes.get(*index).copied())
.collect::<Vec<_>>();
let missing_event_ids = expected_codes
.iter()
.enumerate()
.filter_map(|(index, _)| (!paired_server_event_ids.contains(&index)).then_some(index))
.collect::<Vec<_>>();
let missing_codes = missing_event_ids
.iter()
.filter_map(|index| expected_codes.get(*index).copied())
.collect::<Vec<_>>();
let unknown_pair_identity_count = report
.paired_events
.iter()
.filter(|event| event.server_event_id.is_none() || event.event_code.is_none())
.count();
Some(SignatureCoverage {
expected_event_count: expected_codes.len(),
expected_codes: expected_codes.to_vec(),
paired_event_count: paired_server_event_ids.len(),
paired_server_event_ids,
paired_codes,
missing_event_ids,
missing_codes,
unknown_pair_identity_count,
})
}
#[cfg(not(coverage))]
fn format_signature_coverage(coverage: Option<&SignatureCoverage>) -> String {
let Some(coverage) = coverage else {
return String::new();
};
let missing_ids = format_usize_list(&coverage.missing_event_ids);
let missing_codes = format_u32_list(&coverage.missing_codes);
format!(
"- expected coded signatures: {}\n- paired coded signatures: {}/{}\n- missing paired signature ids: {}\n- missing paired signature codes: {}\n- paired signatures without identity: {}\n",
coverage.expected_event_count,
coverage.paired_event_count,
coverage.expected_event_count,
missing_ids,
missing_codes,
coverage.unknown_pair_identity_count
)
}
#[cfg(not(coverage))]
fn unpaired_video_onsets(report: &SyncAnalysisReport) -> Vec<f64> {
unpaired_onsets(
&report.video_onsets_s,
&report
.paired_events
.iter()
.map(|event| event.video_time_s)
.collect::<Vec<_>>(),
)
}
#[cfg(not(coverage))]
fn unpaired_audio_onsets(report: &SyncAnalysisReport) -> Vec<f64> {
unpaired_onsets(
&report.audio_onsets_s,
&report
.paired_events
.iter()
.map(|event| event.audio_time_s)
.collect::<Vec<_>>(),
)
}
#[cfg(not(coverage))]
fn unpaired_onsets(all_onsets: &[f64], paired_onsets: &[f64]) -> Vec<f64> {
const SAME_ONSET_EPSILON_S: f64 = 0.000_001;
all_onsets
.iter()
.copied()
.filter(|onset| {
!paired_onsets
.iter()
.any(|paired| (paired - onset).abs() <= SAME_ONSET_EPSILON_S)
})
.collect()
}
#[cfg(not(coverage))]
fn format_onset_list(onsets: &[f64]) -> String {
const MAX_PRINTED_ONSETS: usize = 12;
if onsets.is_empty() {
return "none".to_string();
}
let mut formatted = onsets
.iter()
.take(MAX_PRINTED_ONSETS)
.map(|onset| format!("{onset:.3}s"))
.collect::<Vec<_>>();
if onsets.len() > MAX_PRINTED_ONSETS {
formatted.push(format!("...+{}", onsets.len() - MAX_PRINTED_ONSETS));
}
formatted.join(", ")
}
#[cfg(not(coverage))]
fn format_usize_list(values: &[usize]) -> String {
if values.is_empty() {
return "none".to_string();
}
values
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(not(coverage))]
fn format_u32_list(values: &[u32]) -> String {
if values.is_empty() {
return "none".to_string();
}
values
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(", ")
}
2026-04-30 22:23:29 -03:00
#[cfg(not(coverage))]
fn write_report_dir(
report_dir: &std::path::Path,
human_report: &str,
output: &SyncAnalyzeOutput<'_>,
) -> Result<()> {
std::fs::create_dir_all(report_dir)
.with_context(|| format!("creating report directory {}", report_dir.display()))?;
std::fs::write(report_dir.join("report.txt"), human_report)
.with_context(|| format!("writing {}", report_dir.join("report.txt").display()))?;
std::fs::write(
report_dir.join("report.json"),
serde_json::to_string_pretty(output).context("serializing JSON report")?,
)
.with_context(|| format!("writing {}", report_dir.join("report.json").display()))?;
write_events_csv(&report_dir.join("events.csv"), output.report)?;
Ok(())
}
#[cfg(not(coverage))]
fn write_events_csv(path: &std::path::Path, report: &SyncAnalysisReport) -> Result<()> {
let mut csv = String::from(
"event_id,server_event_id,event_code,video_time_s,audio_time_s,skew_ms,confidence\n",
);
2026-04-30 22:23:29 -03:00
for event in &report.paired_events {
csv.push_str(&format!(
"{},{},{},{:.9},{:.9},{:.6},{:.6}\n",
event.event_id,
optional_usize(event.server_event_id),
optional_u32(event.event_code),
event.video_time_s,
event.audio_time_s,
event.skew_ms,
event.confidence
2026-04-30 22:23:29 -03:00
));
}
std::fs::write(path, csv).with_context(|| format!("writing {}", path.display()))
}
#[cfg(not(coverage))]
fn optional_usize(value: Option<usize>) -> String {
value.map(|value| value.to_string()).unwrap_or_default()
}
#[cfg(not(coverage))]
fn optional_u32(value: Option<u32>) -> String {
value.map(|value| value.to_string()).unwrap_or_default()
}
#[cfg(coverage)]
fn main() {}
#[cfg(test)]
mod tests {
use super::parse_args;
use lesavka_client::sync_probe::analyze::{
SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation, SyncEventPair,
};
#[test]
fn parse_args_accepts_capture_path_and_json_flag() {
2026-04-30 22:23:29 -03:00
let args = parse_args(["capture.mkv", "--json"]).expect("args");
assert_eq!(args.capture_path, std::path::PathBuf::from("capture.mkv"));
assert!(args.emit_json);
assert_eq!(args.report_dir, None);
}
#[test]
fn parse_args_accepts_report_dir() {
let args = parse_args(["capture.mkv", "--report-dir", "/tmp/probe"]).expect("args");
assert_eq!(args.capture_path, std::path::PathBuf::from("capture.mkv"));
assert_eq!(
args.report_dir,
Some(std::path::PathBuf::from("/tmp/probe"))
);
}
#[test]
fn parse_args_accepts_event_width_codes() {
let args = parse_args(["capture.mkv", "--event-width-codes", "1,2,1,3"]).expect("args");
assert_eq!(args.options.event_width_codes, vec![1, 2, 1, 3]);
}
#[test]
fn parse_args_accepts_analysis_window() {
let args = parse_args(["capture.mkv", "--analysis-window-s", "8.25:26.5"]).expect("args");
assert_eq!(args.options.analysis_start_s, Some(8.25));
assert_eq!(args.options.analysis_end_s, Some(26.5));
}
#[test]
fn parse_args_rejects_extra_positional_arguments() {
assert!(parse_args(["one.mkv", "two.mkv"]).is_err());
assert!(parse_args(["one.mkv", "--event-width-codes", ""]).is_err());
assert!(parse_args(["one.mkv", "--event-width-codes", "0"]).is_err());
assert!(parse_args(["one.mkv", "--analysis-window-s", "wat:10"]).is_err());
assert!(parse_args(["one.mkv", "--analysis-start-s", "-1"]).is_err());
}
#[test]
fn parse_args_requires_a_capture_path() {
let error = parse_args(["--json"]).expect_err("missing capture path should fail");
assert!(
error.to_string().contains("capture path is required"),
"unexpected error: {error:#}"
);
}
#[test]
fn coverage_main_stub_is_non_panicking() {
let _ = super::main();
}
#[test]
fn human_report_explains_coded_raw_activity_and_unpaired_onsets() {
let report = SyncAnalysisReport {
video_event_count: 3,
audio_event_count: 3,
paired_event_count: 1,
coded_events: true,
activity_start_delta_ms: -3_620.7,
raw_first_video_activity_s: 9.361,
raw_first_audio_activity_s: 5.740,
first_skew_ms: -188.4,
last_skew_ms: -188.4,
mean_skew_ms: -188.4,
median_skew_ms: -188.4,
max_abs_skew_ms: 188.4,
drift_ms: 0.0,
skews_ms: vec![-188.4],
video_onsets_s: vec![9.461, 11.420, 13.367],
audio_onsets_s: vec![9.135, 11.146, 13.135],
paired_events: vec![SyncEventPair {
event_id: 0,
server_event_id: None,
event_code: None,
video_time_s: 11.420,
audio_time_s: 11.146,
skew_ms: -188.4,
confidence: 0.62,
}],
};
let calibration = SyncCalibrationRecommendation {
ready: false,
recommended_audio_offset_adjust_us: 0,
recommended_video_offset_adjust_us: 0,
note: "need more pairs".to_string(),
};
let verdict = SyncAnalysisVerdict {
status: "gross_failure".to_string(),
passed: false,
p95_abs_skew_ms: 188.4,
max_abs_skew_ms: 188.4,
preferred_p95_abs_skew_ms: 35.0,
acceptable_p95_abs_skew_ms: 80.0,
gross_failure_p95_abs_skew_ms: 250.0,
catastrophic_max_abs_skew_ms: 1_000.0,
reason: "coded pulse skew is too high".to_string(),
};
let text = super::format_human_report(
std::path::Path::new("/tmp/capture.webm"),
&report,
None,
&calibration,
&verdict,
);
assert!(text.contains("- evidence mode: coded pulses"));
assert!(text.contains("raw activity handling: reported only"));
assert!(text.contains("- paired window first video/audio: 11.420 s / 11.146 s"));
assert!(text.contains("- unpaired video onsets: 9.461s, 13.367s"));
assert!(text.contains("- unpaired audio onsets: 9.135s, 13.135s"));
}
#[test]
fn signature_coverage_reports_missing_and_unknown_coded_pairs() {
let report = SyncAnalysisReport {
video_event_count: 3,
audio_event_count: 3,
paired_event_count: 2,
coded_events: true,
activity_start_delta_ms: 0.0,
raw_first_video_activity_s: 1.0,
raw_first_audio_activity_s: 1.0,
first_skew_ms: 0.0,
last_skew_ms: 0.0,
mean_skew_ms: 0.0,
median_skew_ms: 0.0,
max_abs_skew_ms: 0.0,
drift_ms: 0.0,
skews_ms: vec![0.0, 0.0],
video_onsets_s: vec![1.0, 2.0, 3.0],
audio_onsets_s: vec![1.0, 2.0, 3.0],
paired_events: vec![
SyncEventPair {
event_id: 0,
server_event_id: Some(0),
event_code: Some(1),
video_time_s: 1.0,
audio_time_s: 1.0,
skew_ms: 0.0,
confidence: 1.0,
},
SyncEventPair {
event_id: 1,
server_event_id: None,
event_code: None,
video_time_s: 2.0,
audio_time_s: 2.0,
skew_ms: 0.0,
confidence: 0.4,
},
],
};
let calibration = SyncCalibrationRecommendation {
ready: false,
recommended_audio_offset_adjust_us: 0,
recommended_video_offset_adjust_us: 0,
note: "need more pairs".to_string(),
};
let verdict = SyncAnalysisVerdict {
status: "insufficient_data".to_string(),
passed: false,
p95_abs_skew_ms: 0.0,
max_abs_skew_ms: 0.0,
preferred_p95_abs_skew_ms: 35.0,
acceptable_p95_abs_skew_ms: 80.0,
gross_failure_p95_abs_skew_ms: 250.0,
catastrophic_max_abs_skew_ms: 1_000.0,
reason: "need more pairs".to_string(),
};
let coverage = super::signature_coverage(&[1, 2, 3], &report);
let text = super::format_human_report(
std::path::Path::new("/tmp/capture.webm"),
&report,
coverage.as_ref(),
&calibration,
&verdict,
);
assert!(text.contains("- expected coded signatures: 3"));
assert!(text.contains("- paired coded signatures: 1/3"));
assert!(text.contains("- missing paired signature ids: 1, 2"));
assert!(text.contains("- missing paired signature codes: 2, 3"));
assert!(text.contains("- paired signatures without identity: 1"));
}
}