Merge branch 'improve-tests-and-comments'
完善单元测试与代码注释。测试 254 → 290(净增 36 个有价值测试), 顺带修复 highlight.rs 中大写语言标识无法走别名映射的 bug。 - theme.rs: 2 → 8(主题切换对合性、预加载脚本契约) - highlight.rs: 7 → 15(别名映射、大小写回退,修复 'RUST' 回退纯文本 bug) - webp.rs: 6 → 16(编解码错误处理、像素格式转换路径、Display 格式) - sanitizer.rs: 8 → 17(is_safe_url 安全敏感分支直接测试) - error.rs: 5 → 8(AppError 各变体消息转换)
This commit is contained in:
commit
928fc1a0de
@ -106,4 +106,37 @@ mod tests {
|
|||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains("文章不存在"), "expected passthrough: {msg}");
|
assert!(msg.contains("文章不存在"), "expected passthrough: {msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_message_passthrough() {
|
||||||
|
// Internal 错误的消息原样透传,便于向用户展示可读的内部错误描述。
|
||||||
|
let err: ServerFnError = AppError::Internal("内部错误").into();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("内部错误"), "expected passthrough: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_hides_sql_details() {
|
||||||
|
// 事务错误同样返回通用提示,不泄露 SQL 细节。
|
||||||
|
let err: ServerFnError = AppError::tx("deadlock detected on UPDATE posts").into();
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(!msg.contains("UPDATE"), "should not leak SQL: {msg}");
|
||||||
|
assert!(
|
||||||
|
!msg.contains("deadlock"),
|
||||||
|
"should not leak error detail: {msg}"
|
||||||
|
);
|
||||||
|
assert!(msg.contains("操作失败"), "expected generic message: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn db_conn_query_transaction_all_return_generic_message() {
|
||||||
|
// 三类数据库错误对外均返回固定中文提示,避免泄露实现细节。
|
||||||
|
let db_conn: ServerFnError = AppError::DbConn("x".into()).into();
|
||||||
|
let query: ServerFnError = AppError::Query("x".into()).into();
|
||||||
|
let tx: ServerFnError = AppError::Transaction("x".into()).into();
|
||||||
|
|
||||||
|
assert!(db_conn.to_string().contains("服务暂时不可用"));
|
||||||
|
assert!(query.to_string().contains("操作失败"));
|
||||||
|
assert!(tx.to_string().contains("操作失败"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -435,4 +435,76 @@ mod tests {
|
|||||||
"<a rel=\"nofollow noopener\">x</a>"
|
"<a rel=\"nofollow noopener\">x</a>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- is_safe_url 直接分支测试 ----
|
||||||
|
// is_safe_url 是安全敏感的内部函数,以下测试锁定其各分支的行为契约。
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_allows_https() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
assert!(is_safe_url("https://example.com", &schemes, false));
|
||||||
|
assert!(is_safe_url("http://example.com", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_rejects_javascript() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
assert!(!is_safe_url("javascript:alert(1)", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_rejects_vbscript() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
assert!(!is_safe_url("vbscript:msgbox", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_data_uri_respects_flag() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
// 文章正文允许 data URI
|
||||||
|
assert!(is_safe_url("data:image/png;base64,iVBOR", &schemes, true));
|
||||||
|
// 评论禁用 data URI
|
||||||
|
assert!(!is_safe_url("data:image/png;base64,iVBOR", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_allows_relative_and_fragment() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
// 绝对路径
|
||||||
|
assert!(is_safe_url("/path/to/page", &schemes, false));
|
||||||
|
// 锚点
|
||||||
|
assert!(is_safe_url("#section", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_empty_is_safe() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
// 空 URL(如 img 无 src)视为安全。
|
||||||
|
assert!(is_safe_url("", &schemes, false));
|
||||||
|
assert!(is_safe_url(" ", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_allows_other_whitelisted_schemes() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
// mailto / tel / ftp 等均在默认白名单中。
|
||||||
|
assert!(is_safe_url("mailto:user@example.com", &schemes, false));
|
||||||
|
assert!(is_safe_url("tel:+8613800138000", &schemes, false));
|
||||||
|
assert!(is_safe_url("ftp://example.com/file", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_rejects_scheme_with_whitespace() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
// 含空格的 scheme 名是已知的混淆手法,应被拒绝。
|
||||||
|
assert!(!is_safe_url("java\tscript:alert(1)", &schemes, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_safe_url_scheme_matching_is_case_insensitive() {
|
||||||
|
let schemes = default_allowed_schemes();
|
||||||
|
// scheme 大小写不敏感:HTTPS 与 https 等价。
|
||||||
|
assert!(is_safe_url("HTTPS://example.com", &schemes, false));
|
||||||
|
assert!(!is_safe_url("JAVASCRIPT:alert(1)", &schemes, false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,8 @@ pub mod server {
|
|||||||
("golang", "go"),
|
("golang", "go"),
|
||||||
];
|
];
|
||||||
for &(from, to) in aliases {
|
for &(from, to) in aliases {
|
||||||
if lang == from {
|
// 别名比较同样不区分大小写,保证 "RUST" 与 "rust" 等价。
|
||||||
|
if lang.eq_ignore_ascii_case(from) {
|
||||||
if let Some(s) = ss.find_syntax_by_extension(to) {
|
if let Some(s) = ss.find_syntax_by_extension(to) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@ -151,4 +152,73 @@ mod tests {
|
|||||||
assert!(result.contains(r#"<span class="storage type rust">let</span>"#));
|
assert!(result.contains(r#"<span class="storage type rust">let</span>"#));
|
||||||
assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
|
assert!(result.contains(r#"<span class="constant numeric integer decimal rust">1</span>"#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_uppercase_language_falls_back_via_lowercase() {
|
||||||
|
// 大写语言标识应通过小写回退路径匹配到对应语法。
|
||||||
|
let lower = highlight_code("fn main() {}", Some("rust"));
|
||||||
|
let upper = highlight_code("fn main() {}", Some("RUST"));
|
||||||
|
// 大写标识的输出必须与小写标识完全一致,证明回退路径生效。
|
||||||
|
assert_eq!(lower, upper);
|
||||||
|
assert!(lower.contains(r#"<span class="storage type function rust">fn</span>"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_resolves_golang_alias() {
|
||||||
|
// 别名表中 "golang" 映射到 "go" 扩展名,输出应与直接用 "go" 一致。
|
||||||
|
let by_alias = highlight_code("package main", Some("golang"));
|
||||||
|
let by_ext = highlight_code("package main", Some("go"));
|
||||||
|
assert_eq!(by_alias, by_ext);
|
||||||
|
// 别名解析必须产出带 span 的高亮输出,而非纯文本。
|
||||||
|
assert!(by_alias.contains("span"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_resolves_bash_alias() {
|
||||||
|
// 别名表中 "bash" 映射到 "sh" 扩展名。
|
||||||
|
let result = highlight_code("echo hello", Some("bash"));
|
||||||
|
assert!(result.contains("span"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_resolves_yml_alias() {
|
||||||
|
// 别名表中 "yml" 映射到 "yaml" 扩展名。
|
||||||
|
let result = highlight_code("key: value", Some("yml"));
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_unknown_language_falls_back_to_plain_text() {
|
||||||
|
// 无法识别的语言应回退到纯文本语法,仍能输出内容。
|
||||||
|
let result = highlight_code("hello world", Some("totally-not-a-language-xyz"));
|
||||||
|
assert!(result.contains("hello world"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_empty_language_string_falls_back_to_plain_text() {
|
||||||
|
// 空字符串语言标识应走纯文本回退路径。
|
||||||
|
let result = highlight_code("just text", Some(""));
|
||||||
|
assert!(result.contains("just text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_trims_surrounding_whitespace() {
|
||||||
|
// 代码首尾的空白会被 trim 掉再高亮。
|
||||||
|
let result = highlight_code(" \nfn main() {}\n ", Some("rust"));
|
||||||
|
assert!(result.contains(r#"<span class="storage type function rust">fn</span>"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlight_code_multiline_output_spans_all_lines() {
|
||||||
|
// 多行代码每一行都应被解析为带 span 的输出。
|
||||||
|
let code = "fn a() {}\nfn b() {}";
|
||||||
|
let result = highlight_code(code, Some("rust"));
|
||||||
|
// 两处 fn 关键字都应出现
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.matches(r#"<span class="storage type function rust">fn</span>"#)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/theme.rs
41
src/theme.rs
@ -210,4 +210,45 @@ mod tests {
|
|||||||
fn toggle_switches_dark_to_light() {
|
fn toggle_switches_dark_to_light() {
|
||||||
assert_eq!(Theme::Dark.toggle(), Theme::Light);
|
assert_eq!(Theme::Dark.toggle(), Theme::Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_is_an_involution() {
|
||||||
|
// 连续切换两次应当回到原始主题。
|
||||||
|
assert_eq!(Theme::Light.toggle().toggle(), Theme::Light);
|
||||||
|
assert_eq!(Theme::Dark.toggle().toggle(), Theme::Dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_derives_equality() {
|
||||||
|
// Theme 派生了 PartialEq,相同变体必须相等。
|
||||||
|
assert_eq!(Theme::Light, Theme::Light);
|
||||||
|
assert_eq!(Theme::Dark, Theme::Dark);
|
||||||
|
assert_ne!(Theme::Light, Theme::Dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_preload_script_adds_dark_class() {
|
||||||
|
// 预加载脚本必须包含给 documentElement 添加 dark class 的逻辑。
|
||||||
|
assert!(THEME_PRELOAD_SCRIPT.contains("classList.add('dark')"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_preload_script_reads_local_storage() {
|
||||||
|
// 预加载脚本必须读取 yggdrasil-theme 键,与 THEME_KEY 保持一致。
|
||||||
|
assert!(THEME_PRELOAD_SCRIPT.contains("localStorage.getItem('yggdrasil-theme')"));
|
||||||
|
assert_eq!(THEME_KEY, "yggdrasil-theme");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_preload_script_falls_back_to_prefers_color_scheme() {
|
||||||
|
// 当 localStorage 中无主题时,脚本应回退到系统颜色偏好。
|
||||||
|
assert!(THEME_PRELOAD_SCRIPT.contains("prefers-color-scheme: dark"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn theme_preload_script_swallows_errors() {
|
||||||
|
// 预加载脚本必须包裹在 try/catch 中,避免禁用 localStorage 时抛错。
|
||||||
|
assert!(THEME_PRELOAD_SCRIPT.contains("try"));
|
||||||
|
assert!(THEME_PRELOAD_SCRIPT.contains("catch"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/webp.rs
84
src/webp.rs
@ -237,4 +237,88 @@ mod tests {
|
|||||||
assert_eq!(0u8.clamp(0, 6), 0);
|
assert_eq!(0u8.clamp(0, 6), 0);
|
||||||
assert_eq!(6u8.clamp(0, 6), 6);
|
assert_eq!(6u8.clamp(0, 6), 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webp_error_encode_display() {
|
||||||
|
let err = WebpError::Encode("boom".to_string());
|
||||||
|
assert_eq!(err.to_string(), "WebP encode error: boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webp_error_decode_display() {
|
||||||
|
let err = WebpError::Decode("busted".to_string());
|
||||||
|
assert_eq!(err.to_string(), "WebP decode error: busted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webp_error_implements_std_error() {
|
||||||
|
// WebpError 必须实现 std::error::Error,才能在 ? 传播链中使用。
|
||||||
|
fn assert_error<T: std::error::Error>() {}
|
||||||
|
assert_error::<WebpError>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_converts_luma8_to_rgba() {
|
||||||
|
// Luma8(灰度)图像不在 encode 的快速路径中,应被转换为 RGBA8 后编码。
|
||||||
|
let img = image::DynamicImage::new_luma8(4, 4);
|
||||||
|
let result = encode(&img, 80.0, 2);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(!result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_converts_luma_a8_to_rgba() {
|
||||||
|
// LumaA8(带 alpha 的灰度)同样走转换路径。
|
||||||
|
let img = image::DynamicImage::new_luma_a8(4, 4);
|
||||||
|
let result = encode(&img, 80.0, 2);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(!result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_lower_quality_does_not_explode_on_solid_color() {
|
||||||
|
// 纯色图是 WebP 的极端情况(信息熵接近 0),确保高低质量都能编码成功
|
||||||
|
// 而非 panic,且产物是合法非空字节流。不假设低质量体积一定更小,
|
||||||
|
// 因为这依赖底层 libwebp 的量化策略,非确定性不变量。
|
||||||
|
let img = image::DynamicImage::new_rgb8(64, 64);
|
||||||
|
let high = encode(&img, 95.0, 4).unwrap();
|
||||||
|
let low = encode(&img, 10.0, 4).unwrap();
|
||||||
|
assert!(!high.is_empty());
|
||||||
|
assert!(!low.is_empty());
|
||||||
|
// 两者都应是合法的 WebP(能被本模块解码回来)。
|
||||||
|
assert!(decode(&high).is_ok());
|
||||||
|
assert!(decode(&low).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_invalid_bytes_returns_error() {
|
||||||
|
// 非 WebP 字节流应返回解码错误而非 panic。
|
||||||
|
let junk = b"this is definitely not a webp image";
|
||||||
|
let result = decode(junk);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_empty_bytes_returns_error() {
|
||||||
|
// 空字节流应返回解码错误而非 panic。
|
||||||
|
let result = decode(&[]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_error_message_is_descriptive() {
|
||||||
|
// 解码错误的 Display 应包含 'WebP decode error' 前缀,便于日志排查。
|
||||||
|
let err = decode(b"not webp").unwrap_err();
|
||||||
|
assert!(err.to_string().starts_with("WebP decode error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_decode_preserves_dimensions() {
|
||||||
|
// 编码再解码后,图像宽高应保持一致。
|
||||||
|
let original = image::DynamicImage::new_rgb8(16, 9);
|
||||||
|
let encoded = encode(&original, 85.0, 4).unwrap();
|
||||||
|
let decoded = decode(&encoded).unwrap();
|
||||||
|
assert_eq!(decoded.width(), 16);
|
||||||
|
assert_eq!(decoded.height(), 9);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user