From 4f368e6fb88d2b3ee61c230eca403188c4f10088 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 9 Jun 2026 09:25:44 +0800 Subject: [PATCH] test: add 122 unit tests across 12 modules Cover utils/text, api/slug, auth/password, auth/session, models/post, models/user, api/auth validation, api/markdown, api/image, highlight, api/rate_limit. Add make test target. --- Cargo.toml | 3 + Makefile | 5 +- src/api/auth.rs | 75 +++++++++++++++++++++ src/api/image.rs | 150 ++++++++++++++++++++++++++++++++++++++++++ src/api/markdown.rs | 119 +++++++++++++++++++++++++++++++++ src/api/rate_limit.rs | 34 ++++++++++ src/api/slug.rs | 77 ++++++++++++++++++++++ src/auth/password.rs | 43 ++++++++++++ src/auth/session.rs | 61 +++++++++++++++++ src/highlight.rs | 49 ++++++++++++++ src/models/post.rs | 93 ++++++++++++++++++++++++++ src/models/user.rs | 50 ++++++++++++++ src/utils/text.rs | 98 +++++++++++++++++++++++++++ 13 files changed, 856 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c65e13f..e9b961d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" +[dev-dependencies] +tokio = { version = "1.52", features = ["full"] } + [profile.release] debug = false opt-level = "z" diff --git a/Makefile b/Makefile index 4b8bb22..71c2932 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev build css css-watch clean build-editor highlight-css +.PHONY: dev build css css-watch clean build-editor highlight-css test build: @$(MAKE) build-editor @@ -27,6 +27,9 @@ css: css-watch: @tailwindcss -i input.css -o public/style.css --watch +test: + @cargo test + clean: @cargo clean @rm -f public/style.css diff --git a/src/api/auth.rs b/src/api/auth.rs index 4d63eae..44e9166 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -309,3 +309,78 @@ pub async fn get_current_user() -> Result { Ok(CurrentUserResponse { user }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_username_valid() { + assert!(validate_username("admin").is_ok()); + assert!(validate_username("user_123").is_ok()); + assert!(validate_username("abc").is_ok()); + } + + #[test] + fn validate_username_too_short() { + assert!(validate_username("ab").is_err()); + } + + #[test] + fn validate_username_too_long() { + assert!(validate_username(&"a".repeat(51)).is_err()); + } + + #[test] + fn validate_username_max_length() { + assert!(validate_username(&"a".repeat(50)).is_ok()); + } + + #[test] + fn validate_username_special_chars() { + assert!(validate_username("user name").is_err()); + assert!(validate_username("user@name").is_err()); + assert!(validate_username("user-name").is_err()); + } + + #[test] + fn validate_username_unicode() { + assert!(validate_username("用户名").is_ok()); + } + + #[test] + fn validate_email_valid() { + assert!(validate_email("user@example.com").is_ok()); + assert!(validate_email("a.b+c@domain.co").is_ok()); + } + + #[test] + fn validate_email_invalid() { + assert!(validate_email("notanemail").is_err()); + assert!(validate_email("@domain.com").is_err()); + assert!(validate_email("user@").is_err()); + assert!(validate_email("user@.com").is_err()); + assert!(validate_email("").is_err()); + } + + #[test] + fn validate_password_valid() { + assert!(validate_password("12345678").is_ok()); + assert!(validate_password("a very long password with spaces").is_ok()); + } + + #[test] + fn validate_password_too_short() { + assert!(validate_password("1234567").is_err()); + } + + #[test] + fn validate_password_exactly_8() { + assert!(validate_password("12345678").is_ok()); + } + + #[test] + fn validate_password_empty() { + assert!(validate_password("").is_err()); + } +} diff --git a/src/api/image.rs b/src/api/image.rs index 5cf057b..63ce695 100644 --- a/src/api/image.rs +++ b/src/api/image.rs @@ -296,3 +296,153 @@ pub async fn serve_image(Path(path): Path, Query(params): Query String { slug } + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + #[test] + fn slugify_heading_simple() { + assert_eq!(slugify_heading("Hello World"), "hello-world"); + } + + #[test] + fn slugify_heading_special_chars() { + assert_eq!(slugify_heading("What's new? (2024)"), "what-s-new-2024"); + } + + #[test] + fn slugify_heading_chinese() { + let slug = slugify_heading("你好世界"); + assert!(!slug.is_empty()); + } + + #[test] + fn slugify_heading_collapses_dashes() { + assert_eq!(slugify_heading("a--b"), "a-b"); + } + + #[test] + fn slugify_heading_strips_trailing_dash() { + assert_eq!(slugify_heading("hello!"), "hello"); + } + + #[test] + fn slugify_heading_empty_returns_heading() { + assert_eq!(slugify_heading(""), "heading"); + } + + #[test] + fn clean_html_allows_safe_tags() { + let input = "

Hello world

"; + assert_eq!(clean_html(input), input); + } + + #[test] + fn clean_html_removes_script() { + let input = "

safe

"; + let result = clean_html(input); + assert!(!result.contains("script")); + assert!(result.contains("safe")); + } + + #[test] + fn clean_html_allows_id_attribute() { + let input = "

Title

"; + let result = clean_html(input); + assert!(result.contains("id=\"my-heading\"")); + } + + #[test] + fn clean_html_allows_class_attribute() { + let input = "text"; + let result = clean_html(input); + assert!(result.contains("class=\"highlight\"")); + } + + #[test] + fn generate_toc_html_empty() { + assert_eq!(generate_toc_html(&[]), ""); + } + + #[test] + fn generate_toc_html_single_heading() { + let headings = vec![(2u8, "Title".to_string(), "title".to_string())]; + let html = generate_toc_html(&headings); + assert!(html.contains("href=\"#title\"")); + assert!(html.contains("
    ")); + assert!(html.contains("
")); + } + + #[test] + fn generate_toc_html_nested() { + let headings = vec![ + (2u8, "Chapter".to_string(), "chapter".to_string()), + (3u8, "Section".to_string(), "section".to_string()), + ]; + let html = generate_toc_html(&headings); + assert!(html.contains("href=\"#chapter\"")); + assert!(html.contains("href=\"#section\"")); + let ul_count = html.matches("
    ").count(); + assert_eq!(ul_count, 2); + } + + #[test] + fn render_markdown_simple_paragraph() { + let result = render_markdown_enhanced("Hello **world**"); + assert!(result.html.contains("world")); + assert!(result.toc_html.is_empty()); + } + + #[test] + fn render_markdown_with_heading_generates_toc() { + let result = render_markdown_enhanced("## My Heading\n\nSome text."); + assert!(result.toc_html.contains("My Heading")); + assert!(result.html.contains("id=\"my-heading\"")); + } + + #[test] + fn render_markdown_empty() { + let result = render_markdown_enhanced(""); + assert_eq!(result.html, ""); + assert_eq!(result.toc_html, ""); + } + + #[test] + fn render_markdown_code_block() { + let result = render_markdown_enhanced("```rust\nfn main() {}\n```"); + assert!(result.html.contains("
    "));
    +        assert!(result.html.contains("main"));
    +    }
    +}
    diff --git a/src/api/rate_limit.rs b/src/api/rate_limit.rs
    index 02fbcdf..c208294 100644
    --- a/src/api/rate_limit.rs
    +++ b/src/api/rate_limit.rs
    @@ -87,3 +87,37 @@ pub fn check_upload_limit(ip: &str) -> Result<(), String> {
             .map(|_| ())
             .map_err(|_| "上传过于频繁,请稍后再试".to_string())
     }
    +
    +#[cfg(all(test, feature = "server"))]
    +mod tests {
    +    use super::*;
    +    use http::HeaderMap;
    +
    +    #[test]
    +    fn get_client_ip_from_x_forwarded_for() {
    +        let mut headers = HeaderMap::new();
    +        headers.insert("x-forwarded-for", "1.2.3.4, 5.6.7.8".parse().unwrap());
    +        assert_eq!(get_client_ip(&headers), "1.2.3.4");
    +    }
    +
    +    #[test]
    +    fn get_client_ip_from_x_real_ip() {
    +        let mut headers = HeaderMap::new();
    +        headers.insert("x-real-ip", "9.8.7.6".parse().unwrap());
    +        assert_eq!(get_client_ip(&headers), "9.8.7.6");
    +    }
    +
    +    #[test]
    +    fn get_client_ip_x_forwarded_for_takes_priority() {
    +        let mut headers = HeaderMap::new();
    +        headers.insert("x-forwarded-for", "1.1.1.1".parse().unwrap());
    +        headers.insert("x-real-ip", "2.2.2.2".parse().unwrap());
    +        assert_eq!(get_client_ip(&headers), "1.1.1.1");
    +    }
    +
    +    #[test]
    +    fn get_client_ip_no_headers_returns_unknown() {
    +        let headers = HeaderMap::new();
    +        assert_eq!(get_client_ip(&headers), "unknown");
    +    }
    +}
    diff --git a/src/api/slug.rs b/src/api/slug.rs
    index 61a5570..abd1f0f 100644
    --- a/src/api/slug.rs
    +++ b/src/api/slug.rs
    @@ -79,3 +79,80 @@ pub async fn ensure_unique_slug(
             }
         }
     }
    +
    +#[cfg(all(test, feature = "server"))]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn slugify_ascii_title() {
    +        assert_eq!(slugify("Hello World"), "hello-world");
    +    }
    +
    +    #[test]
    +    fn slugify_special_characters() {
    +        assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
    +    }
    +
    +    #[test]
    +    fn slugify_chinese_characters() {
    +        let slug = slugify("你好世界 hello");
    +        assert!(slug.contains("hello"));
    +    }
    +
    +    #[test]
    +    fn slugify_collapses_dashes() {
    +        assert_eq!(slugify("a---b"), "a-b");
    +    }
    +
    +    #[test]
    +    fn slugify_empty_returns_timestamp() {
    +        let slug = slugify("");
    +        let _: i64 = slug.parse().expect("should be a valid timestamp");
    +    }
    +
    +    #[test]
    +    fn slugify_truncates_at_100_chars() {
    +        let long_title = "a".repeat(200);
    +        assert!(slugify(&long_title).len() <= 100);
    +    }
    +
    +    #[test]
    +    fn slugify_preserves_underscores() {
    +        assert_eq!(slugify("hello_world"), "hello_world");
    +    }
    +
    +    #[test]
    +    fn is_valid_slug_normal() {
    +        assert!(is_valid_slug("hello-world_123"));
    +    }
    +
    +    #[test]
    +    fn is_valid_slug_rejects_empty() {
    +        assert!(!is_valid_slug(""));
    +    }
    +
    +    #[test]
    +    fn is_valid_slug_rejects_too_long() {
    +        let long_slug = "a".repeat(201);
    +        assert!(!is_valid_slug(&long_slug));
    +    }
    +
    +    #[test]
    +    fn is_valid_slug_accepts_max_length() {
    +        let slug = "a".repeat(200);
    +        assert!(is_valid_slug(&slug));
    +    }
    +
    +    #[test]
    +    fn is_valid_slug_rejects_special_chars() {
    +        assert!(!is_valid_slug("hello world"));
    +        assert!(!is_valid_slug("hello.world"));
    +        assert!(!is_valid_slug("hello!world"));
    +    }
    +
    +    #[test]
    +    fn is_valid_slug_accepts_chinese() {
    +        assert!(is_valid_slug("你好-world"));
    +    }
    +}
    diff --git a/src/auth/password.rs b/src/auth/password.rs
    index 2e3b028..d9e5195 100644
    --- a/src/auth/password.rs
    +++ b/src/auth/password.rs
    @@ -22,3 +22,46 @@ pub fn verify_password(password: &str, hash: &str) -> Result Err(e),
         }
     }
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn hash_and_verify_roundtrip() {
    +        let hash = hash_password("mypassword123").unwrap();
    +        assert!(verify_password("mypassword123", &hash).unwrap());
    +    }
    +
    +    #[test]
    +    fn verify_wrong_password_returns_false() {
    +        let hash = hash_password("correctpassword").unwrap();
    +        assert!(!verify_password("wrongpassword", &hash).unwrap());
    +    }
    +
    +    #[test]
    +    fn different_hashes_for_same_password() {
    +        let hash1 = hash_password("samepassword").unwrap();
    +        let hash2 = hash_password("samepassword").unwrap();
    +        assert_ne!(hash1, hash2);
    +    }
    +
    +    #[test]
    +    fn verify_invalid_hash_returns_error() {
    +        let result = verify_password("password", "not-a-valid-hash");
    +        assert!(result.is_err());
    +    }
    +
    +    #[test]
    +    fn hash_empty_password() {
    +        let hash = hash_password("").unwrap();
    +        assert!(verify_password("", &hash).unwrap());
    +    }
    +
    +    #[test]
    +    fn hash_long_password() {
    +        let long_pw = "a".repeat(1000);
    +        let hash = hash_password(&long_pw).unwrap();
    +        assert!(verify_password(&long_pw, &hash).unwrap());
    +    }
    +}
    diff --git a/src/auth/session.rs b/src/auth/session.rs
    index 7f30c92..b8baa52 100644
    --- a/src/auth/session.rs
    +++ b/src/auth/session.rs
    @@ -39,3 +39,64 @@ pub fn get_session_from_ctx() -> Option {
                 .map(|s| s.to_string())
         })
     }
    +
    +#[cfg(all(test, feature = "server"))]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn parse_session_found() {
    +        let header = "session=abc123; path=/";
    +        assert_eq!(parse_session_token(header), Some("abc123"));
    +    }
    +
    +    #[test]
    +    fn parse_session_single_cookie() {
    +        assert_eq!(parse_session_token("session=token456"), Some("token456"));
    +    }
    +
    +    #[test]
    +    fn parse_session_not_found() {
    +        assert_eq!(parse_session_token("other=value"), None);
    +    }
    +
    +    #[test]
    +    fn parse_session_empty_string() {
    +        assert_eq!(parse_session_token(""), None);
    +    }
    +
    +    #[test]
    +    fn parse_session_multiple_cookies() {
    +        let header = "theme=dark; session=my-secret; lang=en";
    +        assert_eq!(parse_session_token(header), Some("my-secret"));
    +    }
    +
    +    #[test]
    +    fn parse_session_empty_value() {
    +        assert_eq!(parse_session_token("session="), Some(""));
    +    }
    +
    +    #[test]
    +    fn parse_session_trailing_semicolon() {
    +        assert_eq!(parse_session_token("session=abc;"), Some("abc"));
    +    }
    +
    +    #[test]
    +    fn generate_token_is_uuid() {
    +        let token = generate_token();
    +        assert!(uuid::Uuid::parse_str(&token).is_ok());
    +    }
    +
    +    #[test]
    +    fn default_expiry_is_future() {
    +        let expiry = default_expiry();
    +        assert!(expiry > chrono::Utc::now());
    +    }
    +
    +    #[test]
    +    fn default_expiry_is_about_30_days() {
    +        let expiry = default_expiry();
    +        let diff = expiry - chrono::Utc::now();
    +        assert!(diff.num_days() >= 29 && diff.num_days() <= 31);
    +    }
    +}
    diff --git a/src/highlight.rs b/src/highlight.rs
    index 1dc4043..326c552 100644
    --- a/src/highlight.rs
    +++ b/src/highlight.rs
    @@ -84,3 +84,52 @@ pub mod server {
             generator.finalize()
         }
     }
    +
    +#[cfg(all(test, feature = "server"))]
    +mod tests {
    +    use super::server::*;
    +
    +    #[test]
    +    fn highlight_code_rust() {
    +        let result = highlight_code("fn main() {}", Some("rust"));
    +        assert!(!result.is_empty());
    +        assert!(result.contains("main"));
    +    }
    +
    +    #[test]
    +    fn highlight_code_javascript_alias() {
    +        let result = highlight_code("console.log('hi')", Some("js"));
    +        assert!(!result.is_empty());
    +        assert!(result.contains("console"));
    +    }
    +
    +    #[test]
    +    fn highlight_code_python_alias() {
    +        let result = highlight_code("print('hi')", Some("python"));
    +        assert!(!result.is_empty());
    +    }
    +
    +    #[test]
    +    fn highlight_code_unknown_language() {
    +        let result = highlight_code("some text", Some("brainfuck"));
    +        assert!(!result.is_empty());
    +    }
    +
    +    #[test]
    +    fn highlight_code_none_language() {
    +        let result = highlight_code("plain text", None);
    +        assert!(!result.is_empty());
    +    }
    +
    +    #[test]
    +    fn highlight_code_empty() {
    +        let result = highlight_code("", None);
    +        assert!(result.is_empty());
    +    }
    +
    +    #[test]
    +    fn highlight_code_produces_span_tags() {
    +        let result = highlight_code("let x = 1;", Some("rust"));
    +        assert!(result.contains('<'));
    +    }
    +}
    diff --git a/src/models/post.rs b/src/models/post.rs
    index 604a5bd..09c5ca5 100644
    --- a/src/models/post.rs
    +++ b/src/models/post.rs
    @@ -96,3 +96,96 @@ pub struct PostStats {
         pub drafts: i64,
         pub published: i64,
     }
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +    use chrono::{TimeZone, Utc};
    +
    +    fn sample_post() -> Post {
    +        Post {
    +            id: 1,
    +            author_id: 1,
    +            title: "Test".to_string(),
    +            slug: "test".to_string(),
    +            summary: None,
    +            content_md: "content".to_string(),
    +            content_html: None,
    +            status: PostStatus::Draft,
    +            published_at: None,
    +            created_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
    +            updated_at: Utc.with_ymd_and_hms(2024, 1, 15, 10, 0, 0).unwrap(),
    +            tags: vec![],
    +            cover_image: None,
    +            reading_time: 1,
    +            word_count: 10,
    +            toc_html: None,
    +            prev_post: None,
    +            next_post: None,
    +        }
    +    }
    +
    +    #[test]
    +    fn post_status_from_str() {
    +        assert_eq!(PostStatus::from_str("draft"), Some(PostStatus::Draft));
    +        assert_eq!(PostStatus::from_str("published"), Some(PostStatus::Published));
    +        assert_eq!(PostStatus::from_str("unknown"), None);
    +        assert_eq!(PostStatus::from_str(""), None);
    +    }
    +
    +    #[test]
    +    fn post_status_as_str() {
    +        assert_eq!(PostStatus::Draft.as_str(), "draft");
    +        assert_eq!(PostStatus::Published.as_str(), "published");
    +    }
    +
    +    #[test]
    +    fn post_status_roundtrip() {
    +        for status in [PostStatus::Draft, PostStatus::Published] {
    +            assert_eq!(PostStatus::from_str(status.as_str()), Some(status.clone()));
    +        }
    +    }
    +
    +    #[test]
    +    fn formatted_date_uses_published_at_when_available() {
    +        let mut post = sample_post();
    +        post.published_at = Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap());
    +        assert_eq!(post.formatted_date(), "2024-06-01");
    +    }
    +
    +    #[test]
    +    fn formatted_date_falls_back_to_created_at() {
    +        let post = sample_post();
    +        assert_eq!(post.formatted_date(), "2024-01-15");
    +    }
    +
    +    #[test]
    +    fn status_label() {
    +        let mut post = sample_post();
    +        post.status = PostStatus::Published;
    +        assert_eq!(post.status_label(), "已发布");
    +        post.status = PostStatus::Draft;
    +        assert_eq!(post.status_label(), "草稿");
    +    }
    +
    +    #[test]
    +    fn status_class_returns_non_empty() {
    +        let mut post = sample_post();
    +        post.status = PostStatus::Published;
    +        assert!(!post.status_class().is_empty());
    +        post.status = PostStatus::Draft;
    +        assert!(!post.status_class().is_empty());
    +    }
    +
    +    #[test]
    +    fn status_badge_class_returns_non_empty() {
    +        let post = sample_post();
    +        assert!(!post.status_badge_class().is_empty());
    +    }
    +
    +    #[test]
    +    fn post_status_serde_roundtrip() {
    +        let json = serde_json::to_string(&PostStatus::Draft).unwrap();
    +        assert_eq!(serde_json::from_str::(&json).unwrap(), PostStatus::Draft);
    +    }
    +}
    diff --git a/src/models/user.rs b/src/models/user.rs
    index 4db59bf..ddd6cd5 100644
    --- a/src/models/user.rs
    +++ b/src/models/user.rs
    @@ -48,3 +48,53 @@ impl From for PublicUser {
             }
         }
     }
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +    use chrono::{TimeZone, Utc};
    +
    +    fn sample_user() -> User {
    +        User {
    +            id: 1,
    +            username: "admin".to_string(),
    +            email: "admin@test.com".to_string(),
    +            password_hash: "hash".to_string(),
    +            role: UserRole::Admin,
    +            created_at: Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(),
    +        }
    +    }
    +
    +    #[test]
    +    fn user_role_from_str() {
    +        assert_eq!(UserRole::from_str("admin"), Some(UserRole::Admin));
    +        assert_eq!(UserRole::from_str("blocked"), Some(UserRole::Blocked));
    +        assert_eq!(UserRole::from_str("unknown"), None);
    +        assert_eq!(UserRole::from_str(""), None);
    +    }
    +
    +    #[test]
    +    fn user_to_public_user_conversion() {
    +        let user = sample_user();
    +        let public: PublicUser = user.clone().into();
    +        assert_eq!(public.id, user.id);
    +        assert_eq!(public.username, user.username);
    +        assert_eq!(public.email, user.email);
    +        assert_eq!(public.role, user.role);
    +        assert_eq!(public.created_at, user.created_at);
    +    }
    +
    +    #[test]
    +    fn public_user_excludes_password_hash() {
    +        let user = sample_user();
    +        let public: PublicUser = user.into();
    +        let json = serde_json::to_string(&public).unwrap();
    +        assert!(!json.contains("password_hash"));
    +    }
    +
    +    #[test]
    +    fn user_role_serde_roundtrip() {
    +        let json = serde_json::to_string(&UserRole::Admin).unwrap();
    +        assert_eq!(serde_json::from_str::(&json).unwrap(), UserRole::Admin);
    +    }
    +}
    diff --git a/src/utils/text.rs b/src/utils/text.rs
    index be9b6ae..1067758 100644
    --- a/src/utils/text.rs
    +++ b/src/utils/text.rs
    @@ -65,3 +65,101 @@ pub fn auto_summary(md: &str) -> String {
         let plain = strip_markdown(md);
         plain.chars().take(200).collect()
     }
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::*;
    +
    +    #[test]
    +    fn strip_markdown_removes_code_blocks() {
    +        let input = "before```code here```after";
    +        assert_eq!(strip_markdown(input), "beforeafter");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_removes_inline_code() {
    +        assert_eq!(strip_markdown("text `code` more"), "text more");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_removes_images() {
    +        assert_eq!(strip_markdown("![alt](url)"), "");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_keeps_link_text() {
    +        assert_eq!(strip_markdown("[click me](https://example.com)"), "click me");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_removes_headings() {
    +        assert_eq!(strip_markdown("## Hello"), "Hello");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_removes_bold_and_italic() {
    +        assert_eq!(strip_markdown("**bold** *italic* __bold__ _italic_"), "bold italic bold italic");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_empty_input() {
    +        assert_eq!(strip_markdown(""), "");
    +    }
    +
    +    #[test]
    +    fn strip_markdown_mixed() {
    +        let md = "# Title\n\nSome **bold** and `code` [link](url)\n\n![img](img.png)";
    +        let result = strip_markdown(md);
    +        assert!(result.contains("Title"));
    +        assert!(result.contains("bold"));
    +        assert!(result.contains("link"));
    +        assert!(!result.contains("img"));
    +        assert!(!result.contains("**"));
    +        assert!(!result.contains("`"));
    +    }
    +
    +    #[test]
    +    fn count_words_english() {
    +        assert_eq!(count_words("hello world"), 2);
    +    }
    +
    +    #[test]
    +    fn count_words_chinese() {
    +        assert_eq!(count_words("你好世界"), 4);
    +    }
    +
    +    #[test]
    +    fn count_words_mixed() {
    +        let count = count_words("Hello 你好 world 世界");
    +        assert_eq!(count, 6);
    +    }
    +
    +    #[test]
    +    fn count_words_with_markdown() {
    +        let count = count_words("# Hello **World**\n\nSome `code` here");
    +        assert_eq!(count, 4);
    +    }
    +
    +    #[test]
    +    fn count_words_empty_returns_one() {
    +        assert_eq!(count_words(""), 1);
    +    }
    +
    +    #[test]
    +    fn auto_summary_truncates_at_200_chars() {
    +        let long_md: String = "a ".repeat(200);
    +        let summary = auto_summary(&long_md);
    +        assert_eq!(summary.chars().count(), 200);
    +    }
    +
    +    #[test]
    +    fn auto_summary_short_input() {
    +        assert_eq!(auto_summary("short"), "short");
    +    }
    +
    +    #[test]
    +    fn auto_summary_strips_markdown() {
    +        let summary = auto_summary("**bold** and `code`");
    +        assert_eq!(summary, "bold and");
    +    }
    +}