diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b356cbaf..4a994aff 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -659,6 +659,52 @@ Table: Instrumented functions and their logging levels and fields. | `find_footnote_end` | trace | `idx` (in), `skip(text)`, return value (out) | | `parse_rows` | trace, debug | `skip(trimmed)`; `row_index`, `cell_count`, and `error_category` events | +### Tracing-event snapshot tests + +The stable structured fields above are pinned by `insta` snapshots so that +accidental changes to a tracing event's level, target, message, or field set +are caught in review. These tests live next to the instrumented code: + +- `src/wrap/inline/fragment_tracing_snapshots.rs` – `fragment classified`. +- `src/wrap/inline/span_helper_tracing_tests.rs` – date-span events. +- `src/wrap/tokenize/parsing_tracing_snapshots.rs` – link, image, and footnote + events. + +Each is wired into its owning module as a `#[cfg(test)]` `#[path = "…"]` +submodule so the snapshot test sits beside the code it pins while keeping the +production module within the 400-line limit. The `.snap` fixtures live under the +neighbouring `snapshots/` directory. + +A test captures events with `tracing-test`'s `#[traced_test]`, then normalizes +the captured lines through the shared +`crate::wrap::tracing_snapshot_support::normalise_event_lines` helper before +asserting the snapshot. + +#### `normalise_event_lines` helper + +`tracing-test` prefixes every captured line with a volatile timestamp and span +context, which would make raw snapshots non-deterministic. +`normalise_event_lines(lines, message)` retains only the lines containing +`message`, strips the volatile prefix up to the event level (`TRACE`/`DEBUG`), +trims trailing whitespace, and joins the survivors. The level, target, message, +and structured fields are preserved verbatim, so the snapshot still fails if any +of those change. + +Re-use policy for this helper: + +- **Ownership.** Owned by the wrap module (`src/wrap/tracing_snapshot_support.rs`) + and gated behind `#[cfg(test)]`; it is `pub(crate)` test-support code, not part + of any public or runtime API. +- **Permitted call-sites.** Only tracing-event snapshot tests. Call it from + inside a `#[traced_test]` function through the injected `logs_assert` closure, + copying the normalized result into an owned buffer before asserting the + snapshot after the closure returns (see the modules listed above for the + canonical shape). +- **Composition.** Do not layer additional normalization on top; if a new event + needs different masking, extend the helper (and this section) rather than + post-processing its output at the call-site, so every snapshot shares one + normalization contract. + ## Fences module The `fences` module in [src/fences.rs](../src/fences.rs) is responsible for diff --git a/src/wrap.rs b/src/wrap.rs index b71a3a12..ec7e0777 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -20,6 +20,8 @@ mod inline; mod link_reference; mod paragraph; mod tokenize; +#[cfg(test)] +pub(crate) mod tracing_snapshot_support; use block::{BULLET_RE, FOOTNOTE_RE}; pub(crate) use block::{BlockKind, classify_block, leading_indent}; pub use blockquote::BlockquotePrefix; diff --git a/src/wrap/inline/fragment.rs b/src/wrap/inline/fragment.rs index ce6c42bb..ece23e2f 100644 --- a/src/wrap/inline/fragment.rs +++ b/src/wrap/inline/fragment.rs @@ -395,3 +395,6 @@ mod proptests { } } } +#[cfg(test)] +#[path = "fragment_tracing_snapshots.rs"] +mod fragment_tracing_snapshots; diff --git a/src/wrap/inline/fragment_tracing_snapshots.rs b/src/wrap/inline/fragment_tracing_snapshots.rs new file mode 100644 index 00000000..32bc5997 --- /dev/null +++ b/src/wrap/inline/fragment_tracing_snapshots.rs @@ -0,0 +1,30 @@ +//! Snapshot test for fragment-classification tracing events. + +use std::cell::RefCell; + +use tracing_test::traced_test; + +use super::InlineFragment; +use crate::wrap::tracing_snapshot_support::normalise_event_lines; + +#[traced_test] +#[test] +fn snapshots_fragment_classified_event() { + let captured = RefCell::new(String::new()); + let fragment = InlineFragment::new("plain".to_string()); + // Assert the observable classification the traced event mirrors; the + // snapshot below is supplementary evidence of the emitted event shape. + assert!(fragment.is_plain(), "a bare word must classify as Plain"); + assert_eq!(fragment.text, "plain"); + logs_assert(|lines| { + captured.replace(normalise_event_lines(lines, "fragment classified")); + (!captured.borrow().is_empty()) + .then_some(()) + .ok_or_else(|| "expected fragment classification event".to_string()) + }); + let event = captured.into_inner(); + + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_snapshot!("fragment-classified-event", event); + }); +} diff --git a/src/wrap/inline/snapshots/determine-token-span-grouped-date-sequence-event.snap b/src/wrap/inline/snapshots/determine-token-span-grouped-date-sequence-event.snap new file mode 100644 index 00000000..a976fc54 --- /dev/null +++ b/src/wrap/inline/snapshots/determine-token-span-grouped-date-sequence-event.snap @@ -0,0 +1,5 @@ +--- +source: src/wrap/inline/span_helper_tracing_tests.rs +expression: event +--- +TRACE snapshots_grouped_date_sequence_event: mdtablefix::wrap::inline: determine_token_span grouped date sequence start=0 end=5 width=18 diff --git a/src/wrap/inline/snapshots/fragment-classified-event.snap b/src/wrap/inline/snapshots/fragment-classified-event.snap new file mode 100644 index 00000000..b3fc1859 --- /dev/null +++ b/src/wrap/inline/snapshots/fragment-classified-event.snap @@ -0,0 +1,5 @@ +--- +source: src/wrap/inline/fragment_tracing_snapshots.rs +expression: event +--- +DEBUG snapshots_fragment_classified_event: mdtablefix::wrap::inline::fragment: fragment classified token=plain truncated=false kind=Plain diff --git a/src/wrap/inline/snapshots/matched-date-sequence-event.snap b/src/wrap/inline/snapshots/matched-date-sequence-event.snap new file mode 100644 index 00000000..f6a826cb --- /dev/null +++ b/src/wrap/inline/snapshots/matched-date-sequence-event.snap @@ -0,0 +1,5 @@ +--- +source: src/wrap/inline/span_helper_tracing_tests.rs +expression: event +--- +DEBUG snapshots_matched_date_sequence_event:date_token_span{start=0}:try_match_date_sequence{start=0}: mdtablefix::wrap::inline::span_helpers: matched date sequence start=0 end=5 pattern="ordinal_day_month_year" diff --git a/src/wrap/inline/span_helper_tracing_tests.rs b/src/wrap/inline/span_helper_tracing_tests.rs index 4aefff6a..1cba5aeb 100644 --- a/src/wrap/inline/span_helper_tracing_tests.rs +++ b/src/wrap/inline/span_helper_tracing_tests.rs @@ -1,10 +1,12 @@ //! Traced-event tests for inline span helper instrumentation. +use std::cell::RefCell; + use rstest::{fixture, rstest}; use tracing_test::traced_test; use super::{date_token_span, try_match_date_sequence}; -use crate::wrap::inline::determine_token_span; +use crate::wrap::{inline::determine_token_span, tracing_snapshot_support::normalise_event_lines}; #[fixture] fn date_tokens() -> Vec { @@ -30,21 +32,24 @@ fn colon_footnote_tokens() -> Vec { #[traced_test] #[rstest] fn try_match_date_sequence_emits_trace_event(date_tokens: Vec) { - let _ = try_match_date_sequence(&date_tokens, 0); + // Observable result: all five date tokens form one sequence (exclusive end). + assert_eq!(try_match_date_sequence(&date_tokens, 0), Some(5)); assert!(logs_contain("try_match_date_sequence")); } #[traced_test] #[rstest] fn try_match_date_sequence_logs_matched_pattern(date_tokens: Vec) { - let _ = try_match_date_sequence(&date_tokens, 0); + assert_eq!(try_match_date_sequence(&date_tokens, 0), Some(5)); assert!(logs_contain("ordinal_day_month_year")); } #[traced_test] #[rstest] fn date_token_span_emits_trace_event(date_tokens: Vec) { - let _ = date_token_span(&date_tokens, 0); + // Observable result: the span covers all five tokens with display width 18 + // ("25th December 2025"); no footnote coupling applies here. + assert_eq!(date_token_span(&date_tokens, 0), Some((5, 18))); assert!(logs_contain("date_token_span")); } @@ -81,9 +86,68 @@ fn grouping_boundary_logs_declined_footnote_coupling( .iter() .map(|token| (*token).to_string()) .collect::>(); - let _ = determine_token_span(&tokens, 0); + let (end, width) = determine_token_span(&tokens, 0); + // Observable edge-case result: a declined coupling groups only the leading + // "word" span (end == 1, never 0), leaving the footnote outside the span. + assert_eq!( + end, 1, + "declined coupling must retain the leading word span" + ); + let grouped = tokens[..end].join(""); + assert_eq!(grouped, "word"); + assert_eq!(width, 4); + assert!( + !grouped.contains("[^note]"), + "declined coupling must not group the footnote reference", + ); assert!(logs_contain(&format!( "error_category=\"{error_category}\"" ))); assert!(!logs_contain("[^note]")); } + +#[traced_test] +#[rstest] +fn snapshots_grouped_date_sequence_event(date_tokens: Vec) { + let captured = RefCell::new(String::new()); + // Observable grouping result: the date tokens form one atomic span of + // width 18. The snapshot below supplements this behavioural assertion. + let (end, width) = determine_token_span(&date_tokens, 0); + assert_eq!(date_tokens[..end].join(""), "25th December 2025"); + assert_eq!(width, 18); + logs_assert(|lines| { + captured.replace(normalise_event_lines( + lines, + "determine_token_span grouped date sequence", + )); + (!captured.borrow().is_empty()) + .then_some(()) + .ok_or_else(|| "expected grouped date sequence event".to_string()) + }); + let event = captured.into_inner(); + + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_snapshot!("determine-token-span-grouped-date-sequence-event", event); + }); +} + +#[traced_test] +#[rstest] +fn snapshots_matched_date_sequence_event(date_tokens: Vec) { + let captured = RefCell::new(String::new()); + // Observable grouping result backing the supplementary snapshot. + let (end, width) = determine_token_span(&date_tokens, 0); + assert_eq!(date_tokens[..end].join(""), "25th December 2025"); + assert_eq!(width, 18); + logs_assert(|lines| { + captured.replace(normalise_event_lines(lines, "matched date sequence")); + (!captured.borrow().is_empty()) + .then_some(()) + .ok_or_else(|| "expected matched date sequence event".to_string()) + }); + let event = captured.into_inner(); + + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_snapshot!("matched-date-sequence-event", event); + }); +} diff --git a/src/wrap/tokenize/parsing.rs b/src/wrap/tokenize/parsing.rs index 90156766..39877312 100644 --- a/src/wrap/tokenize/parsing.rs +++ b/src/wrap/tokenize/parsing.rs @@ -221,3 +221,7 @@ pub(super) fn handle_backtick_fence(text: &str, start_idx: usize) -> (String, us #[cfg(test)] #[path = "parsing_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "parsing_tracing_snapshots.rs"] +mod parsing_tracing_snapshots; diff --git a/src/wrap/tokenize/parsing_tracing_snapshots.rs b/src/wrap/tokenize/parsing_tracing_snapshots.rs new file mode 100644 index 00000000..fbedea01 --- /dev/null +++ b/src/wrap/tokenize/parsing_tracing_snapshots.rs @@ -0,0 +1,52 @@ +//! Snapshot tests for parsing tracing events. + +use std::cell::RefCell; + +use tracing_test::traced_test; + +use super::parse_link_or_image; +use crate::wrap::tracing_snapshot_support::normalise_event_lines; + +#[traced_test] +#[test] +fn snapshots_footnote_reference_parsed_event() { + let captured = RefCell::new(String::new()); + // Observable parse result: the footnote reference is kept atomic and the + // cursor advances past it. The snapshot is supplementary. + let (token, idx) = parse_link_or_image("[^4] tail", 0); + assert_eq!(token, "[^4]"); + assert_eq!(idx, token.len()); + logs_assert(|lines| { + captured.replace(normalise_event_lines(lines, "footnote reference parsed")); + (!captured.borrow().is_empty()) + .then_some(()) + .ok_or_else(|| "expected footnote reference parsed event".to_string()) + }); + let event = captured.into_inner(); + + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_snapshot!("footnote-reference-parsed-event", event); + }); +} + +#[traced_test] +#[test] +fn snapshots_link_or_image_parsed_event() { + let captured = RefCell::new(String::new()); + // Observable parse result: the whole link is kept atomic. Supplementary + // snapshot follows. + let (token, idx) = parse_link_or_image("[link](url)", 0); + assert_eq!(token, "[link](url)"); + assert_eq!(idx, token.len()); + logs_assert(|lines| { + captured.replace(normalise_event_lines(lines, "link or image parsed")); + (!captured.borrow().is_empty()) + .then_some(()) + .ok_or_else(|| "expected link or image parsed event".to_string()) + }); + let event = captured.into_inner(); + + insta::with_settings!({prepend_module_to_snapshot => false}, { + insta::assert_snapshot!("link-or-image-parsed-event", event); + }); +} diff --git a/src/wrap/tokenize/snapshots/footnote-reference-parsed-event.snap b/src/wrap/tokenize/snapshots/footnote-reference-parsed-event.snap new file mode 100644 index 00000000..163a322a --- /dev/null +++ b/src/wrap/tokenize/snapshots/footnote-reference-parsed-event.snap @@ -0,0 +1,5 @@ +--- +source: src/wrap/tokenize/parsing_tracing_snapshots.rs +expression: event +--- +DEBUG snapshots_footnote_reference_parsed_event:parse_link_or_image{idx=0}: mdtablefix::wrap::tokenize::parsing: footnote reference parsed token_length=4 diff --git a/src/wrap/tokenize/snapshots/link-or-image-parsed-event.snap b/src/wrap/tokenize/snapshots/link-or-image-parsed-event.snap new file mode 100644 index 00000000..b9a0927c --- /dev/null +++ b/src/wrap/tokenize/snapshots/link-or-image-parsed-event.snap @@ -0,0 +1,5 @@ +--- +source: src/wrap/tokenize/parsing_tracing_snapshots.rs +expression: event +--- +DEBUG snapshots_link_or_image_parsed_event:parse_link_or_image{idx=0}: mdtablefix::wrap::tokenize::parsing: link or image parsed token_length=11 is_image=false diff --git a/src/wrap/tracing_snapshot_support.rs b/src/wrap/tracing_snapshot_support.rs new file mode 100644 index 00000000..4a55b8bd --- /dev/null +++ b/src/wrap/tracing_snapshot_support.rs @@ -0,0 +1,126 @@ +//! Stable capture support for tracing-event snapshots. +//! +//! Call this helper inside a `#[traced_test]` function through its injected +//! `logs_assert` closure. Copy the normalized result into an owned buffer +//! before passing it to `insta::assert_snapshot!` after the closure returns. + +/// Normalizes captured tracing lines for a single event message. +/// +/// Retains only lines containing `message`, removes the volatile prefix before +/// the event level, trims trailing whitespace, and joins the remaining lines. +/// The level, target, message, and structured fields remain intact. +/// +/// # Examples +/// +/// ```ignore +/// // `tracing-test` prepends a volatile timestamp and span context before the +/// // level; only the level onward is stable enough to snapshot. +/// let lines = [ +/// "2026-07-26T00:00:00Z DEBUG snapshots: mdtablefix::wrap: fragment classified kind=Plain ", +/// "2026-07-26T00:00:00Z TRACE snapshots: mdtablefix::wrap: predicate matched", +/// ]; +/// let normalized = normalise_event_lines(&lines, "fragment classified"); +/// assert_eq!( +/// normalized, +/// "DEBUG snapshots: mdtablefix::wrap: fragment classified kind=Plain", +/// ); +/// ``` +pub(crate) fn normalise_event_lines(lines: &[&str], message: &str) -> String { + lines + .iter() + .filter(|line| line.contains(message)) + .map(|line| stable_event_start(line).trim_end()) + .collect::>() + .join("\n") +} + +fn stable_event_start(line: &str) -> &str { + ["TRACE", "DEBUG"] + .iter() + .filter_map(|level| { + line.find(level).filter(|&index| { + index == 0 + || line[..index] + .chars() + .next_back() + .is_some_and(char::is_whitespace) + }) + }) + .min() + .map_or(line, |index| &line[index..]) +} + +#[cfg(test)] +mod proptests { + //! Property tests for the normalisation helpers. + //! + //! These prove the prefix-stripping and suffix-preserving contract holds + //! over arbitrary log lines, complementing the fixed event snapshots. + + use proptest::prelude::*; + + use super::{normalise_event_lines, stable_event_start}; + + proptest! { + /// The normalised event start is always a suffix of the input line, and + /// re-normalising it is a no-op (the level already sits at the front). + #[test] + fn stable_event_start_returns_idempotent_suffix(line in "[^\n]*") { + let start = stable_event_start(&line); + prop_assert!(line.ends_with(start)); + prop_assert_eq!(stable_event_start(start), start); + } + + /// Given a synthetic `tracing-test` line — a volatile prefix, the event + /// level, then stable content — the prefix is stripped while the level + /// and every following field survive verbatim. The prefix charset omits + /// the letters in `TRACE`/`DEBUG`, so it cannot masquerade as a level. + #[test] + fn stable_event_start_strips_prefix_and_keeps_event( + prefix in "[0-9TZ:. -]+ ", + level in prop_oneof![Just("TRACE"), Just("DEBUG")], + rest in "[^\n]*", + ) { + let stable = format!("{level} {rest}"); + let line = format!("{prefix}{stable}"); + prop_assert_eq!(stable_event_start(&line), stable.as_str()); + } + + /// `normalise_event_lines` keeps exactly the lines containing `message`, + /// leaves no trailing whitespace, and neither invents nor drops lines. + /// Each output segment must equal the stable-prefix slice of its + /// corresponding matching line, trailing whitespace trimmed. + #[test] + fn normalise_event_lines_filters_and_trims( + lines in prop::collection::vec("[^\n]*", 0..8), + message in "[^\n]{1,4}", + ) { + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let out = normalise_event_lines(&refs, &message); + // The ordered subsequence of input lines that should survive. + let matched: Vec<&str> = refs + .iter() + .copied() + .filter(|line| line.contains(message.as_str())) + .collect(); + + if matched.is_empty() { + prop_assert!(out.is_empty()); + } else { + let segments: Vec<&str> = out.split('\n').collect(); + // Count check: neither invents nor drops lines. + prop_assert_eq!(segments.len(), matched.len()); + for (segment, line) in segments.iter().zip(matched.iter()) { + // Content check: the segment is the matched line with its + // volatile prefix stripped and trailing whitespace trimmed. + prop_assert_eq!(*segment, stable_event_start(line).trim_end()); + // Provenance check (independent of the helper's internals): + // the segment is a trailing slice of its matched input line. + prop_assert!(line.trim_end().ends_with(segment)); + // Trim check: no trailing whitespace survives. + prop_assert_eq!(*segment, segment.trim_end()); + } + } + } + } +}