use std::fmt::Write as _;
use std::path::PathBuf;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use super::*;
const SNAPSHOT_WIDTH: u16 = 100;
const SAMPLE_FILES: &[&str] = &[
"images.md",
"links.md",
"links.ja.md",
"markdown.md",
"markdown.ja.md",
"README.md",
"tutorial.md",
"tutorial.ja.md",
];
fn sample_src(name: &str) -> Option<String> {
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("samples")
.join(name);
match std::fs::read_to_string(&p) {
Ok(s) => Some(s),
Err(e) => {
eprintln!(
"markdown snapshot: skipping samples/{name} ({e}) \
— not present (published-crate build?)"
);
None
}
}
}
pub(super) fn all_cases() -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = Vec::new();
for (name, src) in crate::preview::markdown::task_corpus::cases() {
v.push((format!("task_corpus: {name}"), src.to_string()));
}
for (name, src) in crate::preview::markdown::code_corpus::cases() {
v.push((format!("code_corpus: {name}"), src.to_string()));
}
for case in crate::preview::markdown::code_span_corpus::cases() {
v.push((format!("code_span_corpus: {}", case.name), case.src));
}
for (name, src) in crate::preview::markdown::preprocess_corpus::cases() {
v.push((format!("preprocess_corpus: {name}"), src.to_string()));
}
for (name, src) in crate::preview::markdown::inline_corpus::cases() {
v.push((format!("inline_corpus: {name}"), src.to_string()));
}
for (name, src) in crate::preview::markdown::list_corpus::cases() {
v.push((format!("list_corpus: {name}"), src.to_string()));
}
for (name, src) in crate::preview::markdown::html_table_corpus::cases() {
v.push((format!("html_table_corpus: {name}"), src.to_string()));
}
for name in SAMPLE_FILES {
if let Some(src) = sample_src(name) {
v.push((format!("samples: {name}"), src));
}
}
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
pub(super) fn pre_src_for(cfg: &Config, src: &str) -> String {
let body = if cfg.ui.md_frontmatter {
crate::preview::markdown::strip_front_matter(src).1
} else {
src.to_string()
};
let origin = crate::preview::markdown::identity_origin(&body);
let (s, origin) = if cfg.ui.md_footnotes {
crate::preview::markdown::process_footnotes_traced(&body, &origin)
} else {
(body, origin)
};
let (pre_src, _origin) = if cfg.ui.md_inline_html {
crate::preview::markdown::process_inline_html_traced(&s, &origin)
} else {
(s, origin)
};
pre_src
}
struct CaseRender {
lines: Vec<Line<'static>>,
images: Vec<crate::preview::markdown::ImagePlacement>,
items: Vec<MdItem>,
anchors: Vec<(String, usize)>,
extras: crate::preview::markdown::MdRenderExtras,
details_states: Vec<bool>,
}
fn render_case(cfg: &Config, src: &str) -> CaseRender {
render_case_with(cfg, src, Slots::Extracted)
}
fn render_case_at(cfg: &Config, src: &str, w: u16) -> CaseRender {
render_case_with_width(cfg, src, Slots::Extracted, w)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Slots {
Extracted,
PickerLess,
}
fn render_case_with(cfg: &Config, src: &str, slots: Slots) -> CaseRender {
render_case_with_width(cfg, src, slots, SNAPSHOT_WIDTH)
}
fn render_case_with_width(
cfg: &Config,
src: &str,
slots: Slots,
snapshot_width: u16,
) -> CaseRender {
let extract = slots == Slots::Extracted;
let icons = cfg.ui.icons;
let code = crate::preview::markdown::CodeStyle {
bg: cfg.ui.theme.code_bg(),
label_bg: cfg.ui.theme.code_label_bg(),
label_right: cfg.ui.theme.code_label_right(),
tab_width: cfg.ui.tab_width,
wrap: cfg.ui.wrap,
};
let theme = cfg.ui.theme.code_theme.as_str();
let tasks = cfg.ui.md_task_state_chars();
let fm_lines = if cfg.ui.md_frontmatter {
match crate::preview::markdown::strip_front_matter(src) {
(Some(fm), _) => crate::preview::markdown::render_front_matter(&fm, snapshot_width),
(None, _) => Vec::new(),
}
} else {
Vec::new()
};
let pre_src = pre_src_for(cfg, src);
let details_states: Vec<bool> = crate::preview::markdown::collect_details_open(&pre_src)
.into_iter()
.map(|attr| super::md_render::details_default_open(&cfg.ui.md_details, attr))
.collect();
crate::preview::markdown::set_details_open(details_states.clone());
let slot_of = |_: &str, _: Option<u16>| crate::preview::markdown::ImageSlot::Unavailable;
let mermaid_slot = |_: &str| {
if extract {
crate::preview::markdown::MermaidSlot::Image { cols: 20, rows: 5 }
} else {
crate::preview::markdown::MermaidSlot::Text
}
};
let math_slot = |_: &str, _: bool| crate::preview::markdown::MathSlot::Raw;
let (mut lines, mut images, extras) =
crate::preview::markdown::render_markdown_with_images_aligned(
&pre_src,
snapshot_width,
code,
theme,
icons,
&tasks,
&slot_of,
&mermaid_slot,
crate::i18n::tr(
crate::i18n::Lang::resolve(&cfg.ui.lang),
crate::i18n::Msg::MermaidCaption,
),
cfg.ui.md_alerts,
&math_slot,
extract,
cfg.ui.md_block_aligns(),
);
if !fm_lines.is_empty() {
for p in &mut images {
p.line += fm_lines.len();
}
let mut all = fm_lines;
all.extend(lines);
lines = all;
}
let (lines, targets) = collapse_links(lines, icons);
let (lines, targets) = if cfg.ui.md_autolink {
autolink_bare_urls(lines, targets)
} else {
(lines, targets)
};
let lines = if cfg.ui.md_emoji {
substitute_emoji(lines)
} else {
lines
};
let items = build_md_items_from_render(&lines, &targets, &images, &extras);
let anchors = compute_md_anchors(&lines);
CaseRender {
lines,
images,
items,
anchors,
extras,
details_states,
}
}
fn fmt_color(c: Option<Color>) -> String {
match c {
None => "-".to_string(),
Some(Color::Reset) => "reset".to_string(),
Some(Color::Black) => "black".to_string(),
Some(Color::Red) => "red".to_string(),
Some(Color::Green) => "green".to_string(),
Some(Color::Yellow) => "yellow".to_string(),
Some(Color::Blue) => "blue".to_string(),
Some(Color::Magenta) => "magenta".to_string(),
Some(Color::Cyan) => "cyan".to_string(),
Some(Color::Gray) => "gray".to_string(),
Some(Color::DarkGray) => "darkgray".to_string(),
Some(Color::LightRed) => "lightred".to_string(),
Some(Color::LightGreen) => "lightgreen".to_string(),
Some(Color::LightYellow) => "lightyellow".to_string(),
Some(Color::LightBlue) => "lightblue".to_string(),
Some(Color::LightMagenta) => "lightmagenta".to_string(),
Some(Color::LightCyan) => "lightcyan".to_string(),
Some(Color::White) => "white".to_string(),
Some(Color::Rgb(r, g, b)) => format!("rgb({r},{g},{b})"),
Some(Color::Indexed(i)) => format!("idx({i})"),
}
}
fn fmt_modifier(m: Modifier) -> String {
let known = Modifier::BOLD
| Modifier::DIM
| Modifier::ITALIC
| Modifier::UNDERLINED
| Modifier::SLOW_BLINK
| Modifier::RAPID_BLINK
| Modifier::REVERSED
| Modifier::HIDDEN
| Modifier::CROSSED_OUT;
let mut s = String::new();
if m.contains(Modifier::BOLD) {
s.push('B');
}
if m.contains(Modifier::DIM) {
s.push('D');
}
if m.contains(Modifier::ITALIC) {
s.push('I');
}
if m.contains(Modifier::UNDERLINED) {
s.push('U');
}
if m.contains(Modifier::SLOW_BLINK) {
s.push('l');
}
if m.contains(Modifier::RAPID_BLINK) {
s.push('L');
}
if m.contains(Modifier::REVERSED) {
s.push('R');
}
if m.contains(Modifier::HIDDEN) {
s.push('H');
}
if m.contains(Modifier::CROSSED_OUT) {
s.push('X');
}
let leftover = m & !known;
if !leftover.is_empty() {
write!(s, "+0x{:x}", leftover.bits()).unwrap();
}
if s.is_empty() {
s.push('-');
}
s
}
fn fmt_style(s: Style) -> String {
format!(
"fg={},bg={},mod={}",
fmt_color(s.fg),
fmt_color(s.bg),
fmt_modifier(s.add_modifier)
)
}
fn fmt_align(a: Option<Alignment>) -> &'static str {
match a {
None => "-",
Some(Alignment::Left) => "left",
Some(Alignment::Center) => "center",
Some(Alignment::Right) => "right",
}
}
fn fmt_item_kind(k: &MdItemKind) -> String {
match k {
MdItemKind::Link { target } => format!("Link{{target={target:?}}}"),
MdItemKind::Task { state, state_at } => {
format!("Task{{state={state:?},state_at={state_at:?}}}")
}
MdItemKind::CodeBlock { body } => format!("CodeBlock{{body={body:?}}}"),
MdItemKind::MermaidFence { ordinal } => format!("MermaidFence{{ordinal={ordinal}}}"),
MdItemKind::Details { ordinal } => format!("Details{{ordinal={ordinal}}}"),
}
}
fn fmt_item(it: &MdItem) -> String {
format!("line={:04} kind={}", it.line, fmt_item_kind(&it.kind))
}
fn dump_case(cfg: &Config, name: &str, src: &str, out: &mut String) {
let r = render_case(cfg, src);
writeln!(out, "=== {name} ===").unwrap();
writeln!(out, "-- LINES ({}) --", r.lines.len()).unwrap();
for (i, line) in r.lines.iter().enumerate() {
writeln!(
out,
"L{i:04} style={} align={}",
fmt_style(line.style),
fmt_align(line.alignment)
)
.unwrap();
for (si, span) in line.spans.iter().enumerate() {
writeln!(
out,
" s{si:02} {} {:?}",
fmt_style(span.style),
span.content.as_ref()
)
.unwrap();
}
}
writeln!(out, "-- IMAGES ({}) --", r.images.len()).unwrap();
for (i, p) in r.images.iter().enumerate() {
writeln!(
out,
" i{i:02} line={:04} col={:03} cols={:03} rows={:03} fence_ord={:?} alt={:?} url={:?}",
p.line, p.col, p.cols, p.rows, p.fence_ord, p.alt, p.url
)
.unwrap();
}
writeln!(out, "-- CODE_BLOCKS ({}) --", r.extras.code_blocks.len()).unwrap();
for (i, b) in r.extras.code_blocks.iter().enumerate() {
writeln!(out, " c{i:02} {b:?}").unwrap();
}
writeln!(out, "-- TASKS ({}) --", r.extras.tasks.len()).unwrap();
for (i, (state, off)) in r.extras.tasks.iter().enumerate() {
writeln!(out, " t{i:02} state={state:?} state_at={off:04}").unwrap();
}
writeln!(out, "-- DETAILS_OPEN ({}) --", r.details_states.len()).unwrap();
for (i, o) in r.details_states.iter().enumerate() {
writeln!(out, " d{i:02} {o}").unwrap();
}
writeln!(out, "-- ANCHORS ({}) --", r.anchors.len()).unwrap();
for (i, (slug, line)) in r.anchors.iter().enumerate() {
writeln!(out, " a{i:02} line={line:04} slug={slug:?}").unwrap();
}
writeln!(out, "-- ITEMS ({}) --", r.items.len()).unwrap();
for (i, it) in r.items.iter().enumerate() {
writeln!(out, " m{i:02} {}", fmt_item(it)).unwrap();
}
writeln!(out).unwrap();
}
#[test]
fn the_write_back_record_is_the_same_at_every_pane_width() {
let cfg = Config::default();
let (mut blocks, mut tasks) = (0usize, 0usize);
for (name, src) in all_cases() {
let base = render_case_at(&cfg, &src, SNAPSHOT_WIDTH);
blocks += base.extras.code_blocks.len();
tasks += base.extras.tasks.len();
for w in [1u16, 2, 5, 20, 40, 60, 200, 400] {
let at = render_case_at(&cfg, &src, w);
assert_eq!(
at.extras.code_blocks, base.extras.code_blocks,
"{name}: 幅 {w} で `y c` がコピーするソースが変わる"
);
assert_eq!(
at.extras.tasks, base.extras.tasks,
"{name}: 幅 {w} でチェックボックスの書き戻し位置が変わる"
);
assert_eq!(
at.details_states, base.details_states,
"{name}: 幅 {w} で `<details>` の開閉状態が変わる"
);
for p in &at.images {
assert!(
p.col as usize + p.cols as usize <= (w as usize).max(p.cols as usize),
"{name}: 幅 {w} で画像の予約矩形がペインの外から始まっている: {p:?}"
);
}
}
}
assert!(
blocks > 50 && tasks > 50,
"コーパスのコードブロック({blocks})/チェックボックス({tasks})が激減している\
— この番人が検査するものが無くなっている"
);
}
fn dump_variant(cfg: &Config) -> String {
let mut out = String::new();
for (name, src) in all_cases() {
dump_case(cfg, &name, &src, &mut out);
}
out
}
fn snapshot_path(variant: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("snapshots")
.join(format!("markdown_render.{variant}.snap"))
}
fn split_cases(dump: &str) -> Vec<(&str, &str)> {
let mut out: Vec<(&str, &str)> = Vec::new();
let mut header_start: Option<usize> = None;
let mut name: &str = "";
let mut offset = 0usize;
for line in dump.split('\n') {
let line_start = offset;
offset += line.len() + 1;
if let Some(n) = line
.strip_prefix("=== ")
.and_then(|s| s.strip_suffix(" ==="))
{
if let Some(hs) = header_start {
out.push((name, &dump[hs..line_start]));
}
header_start = Some(line_start);
name = n;
}
}
if let Some(hs) = header_start {
out.push((name, &dump[hs..]));
}
out
}
fn first_diff_context(expected: &str, actual: &str, context: usize) -> String {
let el: Vec<&str> = expected.lines().collect();
let al: Vec<&str> = actual.lines().collect();
let n = el.len().min(al.len());
let mut i = 0;
while i < n && el[i] == al[i] {
i += 1;
}
let lo = i.saturating_sub(context);
let e_hi = (i + context + 1).min(el.len());
let a_hi = (i + context + 1).min(al.len());
let mut s = String::new();
writeln!(s, "first differing line within the case (0-based): {i}").unwrap();
writeln!(s, "--- expected[{lo}..{e_hi}] ---").unwrap();
for l in &el[lo..e_hi] {
writeln!(s, "{l}").unwrap();
}
writeln!(s, "--- actual[{lo}..{a_hi}] ---").unwrap();
for l in &al[lo..a_hi] {
writeln!(s, "{l}").unwrap();
}
s
}
pub(super) fn assert_matches_snapshot(path: PathBuf, label: &str, actual: &str) {
if std::env::var_os("KONOMA_UPDATE_SNAPSHOTS").is_some() {
std::fs::create_dir_all(path.parent().expect("snapshot path has a parent dir"))
.expect("create snapshots/ dir");
std::fs::write(&path, actual).expect("write snapshot file");
eprintln!("markdown snapshot: wrote {}", path.display());
return;
}
let Ok(expected) = std::fs::read_to_string(&path) else {
eprintln!(
"markdown snapshot: {} not found — skipping (published-crate build?); \
regenerate with `KONOMA_UPDATE_SNAPSHOTS=1 cargo test`",
path.display()
);
return;
};
if expected == actual {
return;
}
let exp_cases = split_cases(&expected);
let act_cases = split_cases(actual);
for (i, (e, a)) in exp_cases.iter().zip(act_cases.iter()).enumerate() {
if e != a {
let (name, _) = e;
panic!(
"markdown snapshot ({label}) drifted at case #{i} {name:?}\n{}\n\
Re-run with KONOMA_UPDATE_SNAPSHOTS=1 and inspect the diff if this is intentional.",
first_diff_context(e.1, a.1, 3)
);
}
}
panic!(
"markdown snapshot ({label}) drifted: expected {} cases, got {} \
(the set of cases itself changed, not just their content) — \
re-run with KONOMA_UPDATE_SNAPSHOTS=1 if this is intentional.",
exp_cases.len(),
act_cases.len()
);
}
#[test]
fn markdown_render_snapshot_default() {
assert_matches_snapshot(
snapshot_path("default"),
"default",
&dump_variant(&Config::default()),
);
}
#[test]
fn markdown_render_snapshot_code_bg_none() {
let mut cfg = Config::default();
cfg.ui.theme.code_bg = "none".to_string();
assert_matches_snapshot(
snapshot_path("code_bg_none"),
"code_bg_none",
&dump_variant(&cfg),
);
}
#[test]
fn markdown_render_snapshot_md_aligned() {
let mut cfg = Config::default();
cfg.ui.md_table_align = "right".to_string();
cfg.ui.md_image_align = "left".to_string();
assert_matches_snapshot(
snapshot_path("md_aligned"),
"md_aligned",
&dump_variant(&cfg),
);
}
#[test]
fn markdown_render_snapshot_is_deterministic_within_a_process() {
let cfg = Config::default();
let a = dump_variant(&cfg);
let b = dump_variant(&cfg);
assert_eq!(
a, b,
"rendering the golden-snapshot corpus twice in the same process produced different \
output — some part of the render pipeline is not deterministic"
);
}
#[test]
fn markdown_render_snapshot_corpus_is_not_empty() {
let cases = all_cases();
assert!(
cases.len() > 100,
"the golden snapshot corpus looks suspiciously small ({} cases) — \
a corpus-gathering call in `all_cases` probably broke",
cases.len()
);
}
#[test]
fn no_corpus_case_ever_draws_commented_out_text() {
let mut code_bg_none = Config::default();
code_bg_none.ui.theme.code_bg = "none".to_string();
let mut checked = 0usize;
for cfg in [Config::default(), code_bg_none] {
for (name, src) in all_cases() {
if !src.contains("SECRET") {
continue;
}
checked += 1;
let drawn: Vec<String> = render_case(&cfg, &src)
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.to_string()).collect())
.collect();
for (i, line) in drawn.iter().enumerate() {
assert!(
!line.contains("SECRET"),
"{name}: commented-out text reached the screen at line {i}: {line:?}"
);
}
if src.contains(">keep<") {
assert!(
drawn.iter().any(|l| l.contains("keep")),
"{name}: the live content went missing along with the commented-out text \
— drawn: {drawn:?}"
);
}
}
}
assert!(
checked > 0,
"no corpus case carries a `SECRET-…` marker any more — this guard is checking nothing"
);
}
#[test]
fn no_corpus_case_ever_drops_text_outside_a_table_cell() {
let mut code_bg_none = Config::default();
code_bg_none.ui.theme.code_bg = "none".to_string();
let mut checked = 0usize;
for cfg in [Config::default(), code_bg_none] {
for (name, src) in all_cases() {
let markers: Vec<String> = src
.split("LOOSE-")
.skip(1)
.map(|rest| {
let tail: String = rest
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
format!("LOOSE-{tail}")
})
.collect();
if markers.is_empty() {
continue;
}
checked += 1;
let drawn: Vec<String> = render_case(&cfg, &src)
.lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.to_string()).collect())
.collect();
for m in &markers {
assert!(
drawn.iter().any(|l| l.contains(m.as_str())),
"{name}: text outside the table's cells never reached the screen ({m}) \
— drawn: {drawn:?}"
);
}
}
}
assert!(
checked > 0,
"no corpus case carries a `LOOSE-…` marker any more — this guard is checking nothing"
);
}
#[test]
fn golden_items_match_the_live_app_across_the_corpus() {
let dir = crate::test_support::unique_tmp("konoma_golden_items_vs_app");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let root = dir.canonicalize().unwrap();
let f = root.join("doc.md");
let cfg = Config::default();
let (mut bodies, mut states) = (0usize, 0usize);
for (name, src) in all_cases() {
std::fs::write(&f, &src).unwrap();
let mut app = App::new(root.clone(), Config::default()).unwrap();
app.enter_preview(&f);
app.ensure_md_cache(SNAPSHOT_WIDTH);
let live: Vec<String> = app.md_items.iter().map(fmt_item).collect();
let dumped: Vec<String> = render_case_with(&cfg, &src, Slots::PickerLess)
.items
.iter()
.map(fmt_item)
.collect();
assert_eq!(
dumped, live,
"{name}: ゴールデンの ITEMS が実アプリの md_items と食い違う\
(ハーネスが本番から外れている)\n--- src ---\n{src}"
);
for it in &app.md_items {
match &it.kind {
MdItemKind::CodeBlock { body } => {
assert!(
body.is_some(),
"{name}: 画面のコードブロックに `y c` が読むソースが無い\
(レンダラの記録が届いていない)\n--- src ---\n{src}"
);
bodies += 1;
}
MdItemKind::Task { state_at, .. } => {
assert!(
state_at.is_some(),
"{name}: 画面のチェックボックスに書き戻し位置が無い\
(レンダラの記録が届いていない)\n--- src ---\n{src}"
);
states += 1;
}
_ => {}
}
}
for it in &render_case(&cfg, &src).items {
match &it.kind {
MdItemKind::CodeBlock { body } => assert!(
body.is_some(),
"{name}: ゴールデンが dump するモードでコードブロックのソースが欠落\n--- src ---\n{src}"
),
MdItemKind::Task { state_at, .. } => assert!(
state_at.is_some(),
"{name}: ゴールデンが dump するモードでチェックボックスの書き戻し位置が欠落\n--- src ---\n{src}"
),
_ => {}
}
}
}
assert!(
bodies > 50 && states > 50,
"コーパスのコードブロック({bodies})/チェックボックス({states})が激減している\
— この番人が検査するものが無くなっている"
);
std::fs::remove_dir_all(&dir).ok();
}
fn section_count(case_dump: &str, section: &str) -> usize {
let needle = format!("-- {section} (");
let at = case_dump
.find(&needle)
.unwrap_or_else(|| panic!("dump has no `{section}` section:\n{case_dump}"));
let rest = &case_dump[at + needle.len()..];
let end = rest.find(')').expect("section header closes its paren");
rest[..end]
.parse()
.expect("section header count is a number")
}
const RETIRED_SCANNER_FOILS: &[(&str, &str, usize, usize)] = &[
("task_corpus: plain blockquote", "TASKS", 0, 1),
(
"code_corpus: fence inside a plain block quote draws no header (tui-markdown prefixes every line)",
"CODE_BLOCKS",
0,
1,
),
(
"code_corpus: an indented line right after a table block, with no blank line between",
"CODE_BLOCKS",
1,
0,
),
];
#[test]
fn golden_sections_are_the_render_record_not_the_retired_scanners() {
let cfg = Config::default();
let cases = all_cases();
for &(label, section, scanned, recorded) in RETIRED_SCANNER_FOILS {
assert_ne!(
scanned, recorded,
"{label}: 食い違わない組を番人の対照に使っている(検査になっていない)"
);
let (_, src) = cases
.iter()
.find(|(n, _)| n == label)
.unwrap_or_else(|| panic!("corpus case {label:?} is gone — pick a new sentinel foil"));
let pre_src = pre_src_for(&cfg, src);
let details = crate::preview::markdown::collect_details_open(&pre_src);
let n_scanned = match section {
"CODE_BLOCKS" => {
crate::preview::markdown::code_block_source_locs(&pre_src, &details).len()
}
"TASKS" => crate::preview::markdown::task_source_locs(
&pre_src,
&cfg.ui.md_task_state_chars(),
&details,
)
.len(),
other => panic!("unknown section {other:?}"),
};
assert_eq!(
n_scanned, scanned,
"{label}: 退役スキャナの結果が変わった — 対照(foil)として機能しなくなったので選び直すこと"
);
let mut dump = String::new();
dump_case(&cfg, label, src, &mut dump);
assert_eq!(
section_count(&dump, section),
recorded,
"{label}: ゴールデンの {section} 節がレンダラの記録と違う\
(退役スキャナ {n_scanned} 件に戻っていないか)\n{dump}"
);
}
let mut dump = String::new();
let (label, src) = cases
.iter()
.find(|(n, _)| n == "task_corpus: plain blockquote")
.expect("foil case checked above");
dump_case(&cfg, label, src, &mut dump);
assert!(
dump.contains("state_at=Some("),
"ITEMS 節にチェックボックスの書き戻し位置が出ていない\n{dump}"
);
let mut dump = String::new();
let (label, src) = cases
.iter()
.find(|(n, _)| {
n == "code_corpus: fence inside a plain block quote draws no header (tui-markdown prefixes every line)"
})
.expect("foil case checked above");
dump_case(&cfg, label, src, &mut dump);
assert!(
dump.contains("CodeBlock{body=Some("),
"ITEMS 節にコードブロックのソースが出ていない\n{dump}"
);
}