diff --git a/TODO.md b/TODO.md index 22724e7..42e287b 100644 --- a/TODO.md +++ b/TODO.md @@ -9,3 +9,5 @@ - [ ] 检测文件变化 - [x] 文件到顶、末尾时,播放声音提示,增加喇叭icon - [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示 +- [ ] 整理项目文档 +- [ ] 整理测试用的 markdown 文件,现在太乱 diff --git a/fixtures/headings.md b/fixtures/headings.md new file mode 100644 index 0000000..1722a19 --- /dev/null +++ b/fixtures/headings.md @@ -0,0 +1,5 @@ +# Heading One + +## Heading Two + +### Heading Three diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..1544985 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,33 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +pub fn binary_path() -> &'static str { + env!("CARGO_BIN_EXE_termdown") +} + +/// Run the compiled termdown binary against `path` in a controlled test +/// environment: ghostty-like terminal (so kitty graphics emission is enabled), +/// dark theme, and `HOME`/`USERPROFILE` cleared so a developer's +/// `~/.termdown/config.toml` can't leak into the test. Returns raw stdout +/// bytes; callers decide whether to treat it as UTF-8 or scan for kitty APC +/// payloads. +pub fn run_termdown(path: &Path) -> Vec { + let out = Command::new(binary_path()) + .arg("--theme") + .arg("dark") + .arg(path) + .env("TERM_PROGRAM", "ghostty") + .env_remove("HOME") + .env_remove("USERPROFILE") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("termdown should run"); + assert!( + out.status.success(), + "termdown failed on {path:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + out.stdout +} diff --git a/tests/headings.rs b/tests/headings.rs new file mode 100644 index 0000000..4bed473 --- /dev/null +++ b/tests/headings.rs @@ -0,0 +1,128 @@ +mod common; + +use base64::Engine; +use image::ImageReader; +use std::io::Cursor; +use std::path::Path; + +use common::run_termdown; + +/// Walk `out` looking for Kitty graphics protocol APC frames +/// (`ESC _ G
; ESC \`). Frames where the header has `m=1` +/// continue the current image; `m=0` (or absent) terminates it. Returns one +/// decoded PNG byte vector per emitted image. +/// +/// Panics if an APC frame is not terminated by `ESC \` — a regression where +/// termdown truncates output should surface as a clear parse error here, +/// not as a confusing base64-decode failure downstream. +fn extract_kitty_pngs(out: &[u8]) -> Vec> { + let mut images = Vec::new(); + let mut current_b64 = String::new(); + let mut i = 0; + while i + 2 < out.len() { + if !(out[i] == 0x1b && out[i + 1] == b'_' && out[i + 2] == b'G') { + i += 1; + continue; + } + let mut sep = i + 3; + while sep < out.len() && out[sep] != b';' && out[sep] != 0x1b { + sep += 1; + } + let header = std::str::from_utf8(&out[i + 3..sep]).expect("APC header is ASCII"); + + let payload_start = if sep < out.len() && out[sep] == b';' { + sep + 1 + } else { + sep + }; + let mut end = payload_start; + let mut terminated = false; + while end + 1 < out.len() { + if out[end] == 0x1b && out[end + 1] == b'\\' { + terminated = true; + break; + } + end += 1; + } + assert!( + terminated, + "unterminated kitty APC frame starting at byte offset {i}" + ); + let chunk = std::str::from_utf8(&out[payload_start..end]).expect("APC payload is ASCII"); + current_b64.push_str(chunk); + + let more = header.split(',').any(|kv| kv == "m=1"); + if !more { + let png = base64::engine::general_purpose::STANDARD + .decode(¤t_b64) + .expect("base64 payload decodes"); + images.push(png); + current_b64.clear(); + } + + i = end + 2; + } + images +} + +#[test] +fn h1_h2_h3_emit_decodable_pngs_with_descending_heights() { + let fixture = Path::new("fixtures/headings.md"); + let stdout = run_termdown(fixture); + + // The very first APC frame must use the display form (`a=T`, transmit + + // display in one go). The lifecycle form (`a=t`, transmit-only, used by + // the TUI for cached placements) would not paint anything when run via + // `cat` mode and would silently regress the heading-rendering pipeline. + let display_header: &[u8] = b"\x1b_Gf=100,a=T"; + assert!( + stdout + .windows(display_header.len()) + .any(|w| w == display_header), + "expected display-form kitty header (\\x1b_Gf=100,a=T) in stdout" + ); + + let pngs = extract_kitty_pngs(&stdout); + + assert_eq!( + pngs.len(), + 3, + "expected one Kitty image per heading level (H1, H2, H3); got {}", + pngs.len() + ); + + let dims: Vec<(u32, u32)> = pngs + .iter() + .enumerate() + .map(|(idx, png)| { + let img = ImageReader::new(Cursor::new(png)) + .with_guessed_format() + .unwrap_or_else(|e| panic!("png {idx} guess_format: {e}")) + .decode() + .unwrap_or_else(|e| panic!("png {idx} decode: {e}")) + .to_rgba8(); + assert!( + img.width() > 0 && img.height() > 0, + "png {idx} has empty dimensions" + ); + assert!( + img.pixels().any(|p| p[3] > 0), + "png {idx} is fully transparent" + ); + (img.width(), img.height()) + }) + .collect(); + + let (_, h1_h) = dims[0]; + let (_, h2_h) = dims[1]; + let (_, h3_h) = dims[2]; + + assert!( + h1_h > h2_h, + "H1 height ({h1_h}) should exceed H2 height ({h2_h})" + ); + assert!( + h2_h > h3_h, + "H2 height ({h2_h}) should exceed H3 height ({h3_h})" + ); +} diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 6cbbe1a..2fae82c 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -1,10 +1,9 @@ +mod common; + use std::fs; use std::path::Path; -use std::process::{Command, Stdio}; -fn binary_path() -> &'static str { - env!("CARGO_BIN_EXE_termdown") -} +use common::run_termdown; /// Replace each run of kitty image APC sequences (`ESC _ G ... ESC \`) with a /// single `` marker. Font rasterization produces OS-specific PNG bytes @@ -39,20 +38,8 @@ fn strip_kitty_images(s: &str) -> String { } fn render(path: &Path) -> String { - let out = Command::new(binary_path()) - .arg("--theme") - .arg("dark") - .arg(path) - .env("TERM_PROGRAM", "ghostty") - .env_remove("HOME") - .env_remove("USERPROFILE") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .expect("termdown should run"); - assert!(out.status.success(), "termdown failed on {path:?}"); - let raw = String::from_utf8(out.stdout).expect("valid utf-8"); + let stdout = run_termdown(path); + let raw = String::from_utf8(stdout).expect("valid utf-8"); strip_kitty_images(&raw) }