refactor: extract inline test modules to separate files
This commit is contained in:
@@ -1,139 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A hashtag extracted from content.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Hashtag {
|
||||
/// Original casing, e.g. "Rust"
|
||||
pub raw: String,
|
||||
/// Lowercased, e.g. "rust" — used for DB lookups
|
||||
pub normalized: String,
|
||||
/// "tags/rust" — callers prepend base_url
|
||||
pub url_slug: String,
|
||||
/// "#rust" — used directly in AP tag array
|
||||
pub ap_name: String,
|
||||
}
|
||||
|
||||
/// Extract hashtags from content using a char-by-char scan.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Tag starts after a bare `#` followed immediately by an alphanumeric char.
|
||||
/// - Tag chars: `[A-Za-z0-9_]`.
|
||||
/// - Deduplicated case-insensitively; first occurrence wins.
|
||||
/// - Returned in order of first appearance.
|
||||
pub fn extract(content: &str) -> Vec<Hashtag> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut tags: Vec<Hashtag> = Vec::new();
|
||||
let mut chars = content.char_indices().peekable();
|
||||
|
||||
while let Some((_, c)) = chars.next() {
|
||||
if c == '#'
|
||||
&& chars
|
||||
.peek()
|
||||
.map(|(_, nc)| nc.is_alphanumeric())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let raw: String = chars
|
||||
.by_ref()
|
||||
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
|
||||
.map(|(_, nc)| nc)
|
||||
.collect();
|
||||
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = raw.to_lowercase();
|
||||
if seen.insert(normalized.clone()) {
|
||||
tags.push(Hashtag {
|
||||
url_slug: format!("tags/{}", normalized),
|
||||
ap_name: format!("#{}", normalized),
|
||||
raw,
|
||||
normalized,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn names(tags: &[Hashtag]) -> Vec<&str> {
|
||||
tags.iter().map(|h| h.normalized.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic() {
|
||||
let tags = extract("Hello #world and #Rust!");
|
||||
assert_eq!(names(&tags), ["world", "rust"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields() {
|
||||
let tags = extract("#Rust");
|
||||
assert_eq!(tags.len(), 1);
|
||||
let h = &tags[0];
|
||||
assert_eq!(h.raw, "Rust");
|
||||
assert_eq!(h.normalized, "rust");
|
||||
assert_eq!(h.url_slug, "tags/rust");
|
||||
assert_eq!(h.ap_name, "#rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_case_insensitive() {
|
||||
let tags = extract("#rust #Rust #RUST");
|
||||
assert_eq!(names(&tags), ["rust"]);
|
||||
assert_eq!(tags[0].raw, "rust"); // first occurrence wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_non_adjacent() {
|
||||
// The old algorithm used Vec::dedup() which only removes adjacent duplicates.
|
||||
// Using HashSet silently fixed this bug. This test documents the fix.
|
||||
let tags = extract("#a #b #a");
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_eq!(tags[0].normalized, "a");
|
||||
assert_eq!(tags[1].normalized, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_word_extracted() {
|
||||
// `text#tag` — `#` not preceded by whitespace is still matched by the
|
||||
// char-by-char scan (the old algorithm didn't require whitespace before `#`).
|
||||
// This test documents the authoritative behaviour: mid-word tags ARE extracted.
|
||||
let tags = extract("text#tag");
|
||||
assert_eq!(names(&tags), ["tag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_only_ignored() {
|
||||
assert!(extract("# lone hash").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_punctuation_excluded() {
|
||||
// punctuation after tag terminates the tag, not included
|
||||
let tags = extract("#rust.");
|
||||
assert_eq!(names(&tags), ["rust"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscore_allowed() {
|
||||
let tags = extract("#hello_world");
|
||||
assert_eq!(names(&tags), ["hello_world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content() {
|
||||
assert!(extract("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_of_appearance() {
|
||||
let tags = extract("#b #a #c");
|
||||
assert_eq!(names(&tags), ["b", "a", "c"]);
|
||||
}
|
||||
}
|
||||
61
crates/domain/src/hashtag/mod.rs
Normal file
61
crates/domain/src/hashtag/mod.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// A hashtag extracted from content.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Hashtag {
|
||||
/// Original casing, e.g. "Rust"
|
||||
pub raw: String,
|
||||
/// Lowercased, e.g. "rust" — used for DB lookups
|
||||
pub normalized: String,
|
||||
/// "tags/rust" — callers prepend base_url
|
||||
pub url_slug: String,
|
||||
/// "#rust" — used directly in AP tag array
|
||||
pub ap_name: String,
|
||||
}
|
||||
|
||||
/// Extract hashtags from content using a char-by-char scan.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Tag starts after a bare `#` followed immediately by an alphanumeric char.
|
||||
/// - Tag chars: `[A-Za-z0-9_]`.
|
||||
/// - Deduplicated case-insensitively; first occurrence wins.
|
||||
/// - Returned in order of first appearance.
|
||||
pub fn extract(content: &str) -> Vec<Hashtag> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut tags: Vec<Hashtag> = Vec::new();
|
||||
let mut chars = content.char_indices().peekable();
|
||||
|
||||
while let Some((_, c)) = chars.next() {
|
||||
if c == '#'
|
||||
&& chars
|
||||
.peek()
|
||||
.map(|(_, nc)| nc.is_alphanumeric())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let raw: String = chars
|
||||
.by_ref()
|
||||
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
|
||||
.map(|(_, nc)| nc)
|
||||
.collect();
|
||||
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = raw.to_lowercase();
|
||||
if seen.insert(normalized.clone()) {
|
||||
tags.push(Hashtag {
|
||||
url_slug: format!("tags/{}", normalized),
|
||||
ap_name: format!("#{}", normalized),
|
||||
raw,
|
||||
normalized,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
71
crates/domain/src/hashtag/tests.rs
Normal file
71
crates/domain/src/hashtag/tests.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
|
||||
fn names(tags: &[Hashtag]) -> Vec<&str> {
|
||||
tags.iter().map(|h| h.normalized.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic() {
|
||||
let tags = extract("Hello #world and #Rust!");
|
||||
assert_eq!(names(&tags), ["world", "rust"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields() {
|
||||
let tags = extract("#Rust");
|
||||
assert_eq!(tags.len(), 1);
|
||||
let h = &tags[0];
|
||||
assert_eq!(h.raw, "Rust");
|
||||
assert_eq!(h.normalized, "rust");
|
||||
assert_eq!(h.url_slug, "tags/rust");
|
||||
assert_eq!(h.ap_name, "#rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_case_insensitive() {
|
||||
let tags = extract("#rust #Rust #RUST");
|
||||
assert_eq!(names(&tags), ["rust"]);
|
||||
assert_eq!(tags[0].raw, "rust"); // first occurrence wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_non_adjacent() {
|
||||
let tags = extract("#a #b #a");
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_eq!(tags[0].normalized, "a");
|
||||
assert_eq!(tags[1].normalized, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_word_extracted() {
|
||||
let tags = extract("text#tag");
|
||||
assert_eq!(names(&tags), ["tag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_only_ignored() {
|
||||
assert!(extract("# lone hash").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_punctuation_excluded() {
|
||||
let tags = extract("#rust.");
|
||||
assert_eq!(names(&tags), ["rust"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underscore_allowed() {
|
||||
let tags = extract("#hello_world");
|
||||
assert_eq!(names(&tags), ["hello_world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content() {
|
||||
assert!(extract("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_of_appearance() {
|
||||
let tags = extract("#b #a #c");
|
||||
assert_eq!(names(&tags), ["b", "a", "c"]);
|
||||
}
|
||||
@@ -802,7 +802,6 @@ impl SearchPort for TestStore {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[async_trait]
|
||||
impl FederationSchedulerPort for TestStore {
|
||||
async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
||||
@@ -863,102 +862,4 @@ impl OutboxWriter for NoOpOutboxWriter {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod federation_port_tests {
|
||||
use super::*;
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
fn uid() -> UserId {
|
||||
UserId::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_lookup_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let err = store.lookup_actor("@alice@example.com").await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_follow_remote_is_noop_ok() {
|
||||
let store = TestStore::default();
|
||||
store
|
||||
.follow_remote(&uid(), "@alice@example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_actor_json_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let err = store.actor_json(&UserId::new()).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_fetch_outbox_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let notes = store
|
||||
.fetch_outbox_page("https://example.com/outbox", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_resolve_actor_profiles_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store
|
||||
.resolve_actor_profiles(vec!["https://example.com/users/alice".into()])
|
||||
.await;
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_fetch_collection_urls_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let urls = store
|
||||
.fetch_actor_urls_from_collection("https://example.com/users/alice/followers")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(urls.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod search_tests {
|
||||
use super::*;
|
||||
use crate::models::feed::PageParams;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_search_thoughts_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store
|
||||
.search_thoughts(
|
||||
"hello",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_search_users_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store
|
||||
.search_users(
|
||||
"alice",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
98
crates/domain/src/testing/tests.rs
Normal file
98
crates/domain/src/testing/tests.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
mod federation_port_tests {
|
||||
use super::super::*;
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
fn uid() -> UserId {
|
||||
UserId::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_lookup_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let err = store.lookup_actor("@alice@example.com").await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_follow_remote_is_noop_ok() {
|
||||
let store = TestStore::default();
|
||||
store
|
||||
.follow_remote(&uid(), "@alice@example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_actor_json_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let err = store.actor_json(&UserId::new()).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_fetch_outbox_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let notes = store
|
||||
.fetch_outbox_page("https://example.com/outbox", 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_resolve_actor_profiles_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store
|
||||
.resolve_actor_profiles(vec!["https://example.com/users/alice".into()])
|
||||
.await;
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_fetch_collection_urls_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let urls = store
|
||||
.fetch_actor_urls_from_collection("https://example.com/users/alice/followers")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(urls.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
mod search_tests {
|
||||
use super::super::*;
|
||||
use crate::models::feed::PageParams;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_search_thoughts_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store
|
||||
.search_thoughts(
|
||||
"hello",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_search_users_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store
|
||||
.search_users(
|
||||
"alice",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
@@ -116,35 +116,4 @@ impl std::fmt::Display for Content {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_rejects_empty() {
|
||||
assert!(Username::new("").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn username_rejects_too_long() {
|
||||
assert!(Username::new("a".repeat(33)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn username_rejects_invalid_chars() {
|
||||
assert!(Username::new("hello world").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn username_accepts_valid() {
|
||||
assert!(Username::new("hello_123").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn content_local_rejects_over_128() {
|
||||
assert!(Content::new_local("a".repeat(129)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn content_local_accepts_128() {
|
||||
assert!(Content::new_local("a".repeat(128)).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn email_rejects_no_at() {
|
||||
assert!(Email::new("notanemail").is_err());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
30
crates/domain/src/value_objects/tests.rs
Normal file
30
crates/domain/src/value_objects/tests.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn username_rejects_empty() {
|
||||
assert!(Username::new("").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn username_rejects_too_long() {
|
||||
assert!(Username::new("a".repeat(33)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn username_rejects_invalid_chars() {
|
||||
assert!(Username::new("hello world").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn username_accepts_valid() {
|
||||
assert!(Username::new("hello_123").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn content_local_rejects_over_128() {
|
||||
assert!(Content::new_local("a".repeat(129)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn content_local_accepts_128() {
|
||||
assert!(Content::new_local("a".repeat(128)).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn email_rejects_no_at() {
|
||||
assert!(Email::new("notanemail").is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user