Files
gitlore/src/cli/commands/who_tests.rs
Taylor Eernisse 7e0e6a91f2 refactor: extract unit tests into separate _tests.rs files
Move inline #[cfg(test)] mod tests { ... } blocks from 22 source files
into dedicated _tests.rs companion files, wired via:

    #[cfg(test)]
    #[path = "module_tests.rs"]
    mod tests;

This keeps implementation-focused source files leaner and more scannable
while preserving full access to private items through `use super::*;`.

Modules extracted:
  core:      db, note_parser, payloads, project, references, sync_run,
             timeline_collect, timeline_expand, timeline_seed
  cli:       list (55 tests), who (75 tests)
  documents: extractor (43 tests), regenerator
  embedding: change_detector, chunking
  gitlab:    graphql (wiremock async tests), transformers/issue
  ingestion: dirty_tracker, discussions, issues, mr_diffs

Also adds conflicts_with("explain_score") to the --detail flag in the
who command to prevent mutually exclusive flags from being combined.

All 629 unit tests pass. No behavior changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:54:02 -05:00

3268 lines
91 KiB
Rust

use super::*;
use crate::core::db::{create_connection, run_migrations};
use std::path::Path;
fn setup_test_db() -> Connection {
let conn = create_connection(Path::new(":memory:")).unwrap();
run_migrations(&conn).unwrap();
conn
}
fn default_scoring() -> ScoringConfig {
ScoringConfig::default()
}
/// as_of_ms value for tests: 1 second after now, giving near-zero decay.
fn test_as_of_ms() -> i64 {
now_ms() + 1000
}
fn insert_project(conn: &Connection, id: i64, path: &str) {
conn.execute(
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![
id,
id * 100,
path,
format!("https://git.example.com/{}", path)
],
)
.unwrap();
}
fn insert_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str) {
let ts = now_ms();
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at, merged_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("MR {iid}"),
author,
state,
ts,
ts,
ts,
if state == "merged" { Some(ts) } else { None::<i64> }
],
)
.unwrap();
}
fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) {
conn.execute(
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'opened', ?6, ?7, ?8, ?9)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("Issue {iid}"),
author,
now_ms(),
now_ms(),
now_ms()
],
)
.unwrap();
}
fn insert_discussion(
conn: &Connection,
id: i64,
project_id: i64,
mr_id: Option<i64>,
issue_id: Option<i64>,
resolvable: bool,
resolved: bool,
) {
let noteable_type = if mr_id.is_some() {
"MergeRequest"
} else {
"Issue"
};
conn.execute(
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, issue_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
format!("disc-{id}"),
project_id,
mr_id,
issue_id,
noteable_type,
i32::from(resolvable),
i32::from(resolved),
now_ms(),
now_ms()
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments)]
fn insert_diffnote(
conn: &Connection,
id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
file_path: &str,
body: &str,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at, position_new_path)
VALUES (?1, ?2, ?3, ?4, 'DiffNote', 0, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
id,
id * 10,
discussion_id,
project_id,
author,
body,
now_ms(),
now_ms(),
now_ms(),
file_path
],
)
.unwrap();
}
fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) {
conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)",
rusqlite::params![issue_id, username],
)
.unwrap();
}
fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) {
conn.execute(
"INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)",
rusqlite::params![mr_id, username],
)
.unwrap();
}
fn insert_file_change(
conn: &Connection,
mr_id: i64,
project_id: i64,
new_path: &str,
change_type: &str,
) {
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type)
VALUES (?1, ?2, ?3, ?4)",
rusqlite::params![mr_id, project_id, new_path, change_type],
)
.unwrap();
}
#[allow(clippy::too_many_arguments, dead_code)]
fn insert_mr_at(
conn: &Connection,
id: i64,
project_id: i64,
iid: i64,
author: &str,
state: &str,
updated_at_ms: i64,
merged_at_ms: Option<i64>,
closed_at_ms: Option<i64>,
) {
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
rusqlite::params![
id,
id * 10,
project_id,
iid,
format!("MR {iid}"),
author,
state,
now_ms(),
updated_at_ms,
merged_at_ms,
closed_at_ms
],
)
.unwrap();
}
#[allow(clippy::too_many_arguments, dead_code)]
fn insert_diffnote_at(
conn: &Connection,
id: i64,
discussion_id: i64,
project_id: i64,
author: &str,
new_path: &str,
old_path: Option<&str>,
body: &str,
created_at_ms: i64,
) {
conn.execute(
"INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9, ?9)",
rusqlite::params![
id,
id * 10,
project_id,
discussion_id,
author,
new_path,
old_path,
body,
created_at_ms
],
)
.unwrap();
}
#[allow(dead_code)]
fn insert_file_change_with_old_path(
conn: &Connection,
mr_id: i64,
project_id: i64,
new_path: &str,
old_path: Option<&str>,
change_type: &str,
) {
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![mr_id, project_id, new_path, old_path, change_type],
)
.unwrap();
}
#[test]
fn test_is_file_path_discrimination() {
// Contains '/' -> file path
assert!(matches!(
resolve_mode(&WhoArgs {
target: Some("src/auth/".to_string()),
path: None,
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: false,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
})
.unwrap(),
WhoMode::Expert { .. }
));
// No '/' -> username
assert!(matches!(
resolve_mode(&WhoArgs {
target: Some("asmith".to_string()),
path: None,
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: false,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
})
.unwrap(),
WhoMode::Workload { .. }
));
// With @ prefix -> username (stripped)
assert!(matches!(
resolve_mode(&WhoArgs {
target: Some("@asmith".to_string()),
path: None,
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: false,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
})
.unwrap(),
WhoMode::Workload { .. }
));
// --reviews flag -> reviews mode
assert!(matches!(
resolve_mode(&WhoArgs {
target: Some("asmith".to_string()),
path: None,
active: false,
overlap: None,
reviews: true,
since: None,
project: None,
limit: 20,
detail: false,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
})
.unwrap(),
WhoMode::Reviews { .. }
));
// --path flag -> expert mode (handles root files)
assert!(matches!(
resolve_mode(&WhoArgs {
target: None,
path: Some("README.md".to_string()),
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: false,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
})
.unwrap(),
WhoMode::Expert { .. }
));
// --path flag with dotless file -> expert mode
assert!(matches!(
resolve_mode(&WhoArgs {
target: None,
path: Some("Makefile".to_string()),
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: false,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
})
.unwrap(),
WhoMode::Expert { .. }
));
}
#[test]
fn test_detail_rejected_outside_expert_mode() {
let args = WhoArgs {
target: Some("asmith".to_string()),
path: None,
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: true,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
};
let mode = resolve_mode(&args).unwrap();
let err = validate_mode_flags(&mode, &args).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("--detail is only supported in expert mode"),
"unexpected error: {msg}"
);
}
#[test]
fn test_detail_allowed_in_expert_mode() {
let args = WhoArgs {
target: None,
path: Some("README.md".to_string()),
active: false,
overlap: None,
reviews: false,
since: None,
project: None,
limit: 20,
detail: true,
no_detail: false,
fields: None,
as_of: None,
explain_score: false,
include_bots: false,
all_history: false,
};
let mode = resolve_mode(&args).unwrap();
assert!(validate_mode_flags(&mode, &args).is_ok());
}
#[test]
fn test_build_path_query() {
let conn = setup_test_db();
// Directory with trailing slash -> prefix
let pq = build_path_query(&conn, "src/auth/", None).unwrap();
assert_eq!(pq.value, "src/auth/%");
assert!(pq.is_prefix);
// Directory without trailing slash (no dot in last segment) -> prefix
let pq = build_path_query(&conn, "src/auth", None).unwrap();
assert_eq!(pq.value, "src/auth/%");
assert!(pq.is_prefix);
// File with extension -> exact
let pq = build_path_query(&conn, "src/auth/login.rs", None).unwrap();
assert_eq!(pq.value, "src/auth/login.rs");
assert!(!pq.is_prefix);
// Root file -> exact
let pq = build_path_query(&conn, "README.md", None).unwrap();
assert_eq!(pq.value, "README.md");
assert!(!pq.is_prefix);
// Directory with dots in non-leaf segment -> prefix
let pq = build_path_query(&conn, ".github/workflows/", None).unwrap();
assert_eq!(pq.value, ".github/workflows/%");
assert!(pq.is_prefix);
// Versioned directory path -> prefix
let pq = build_path_query(&conn, "src/v1.2/auth/", None).unwrap();
assert_eq!(pq.value, "src/v1.2/auth/%");
assert!(pq.is_prefix);
// Path with LIKE metacharacters -> prefix, escaped
let pq = build_path_query(&conn, "src/test_files/", None).unwrap();
assert_eq!(pq.value, "src/test\\_files/%");
assert!(pq.is_prefix);
// Dotless root file -> exact match (root path without '/')
let pq = build_path_query(&conn, "Makefile", None).unwrap();
assert_eq!(pq.value, "Makefile");
assert!(!pq.is_prefix);
let pq = build_path_query(&conn, "LICENSE", None).unwrap();
assert_eq!(pq.value, "LICENSE");
assert!(!pq.is_prefix);
// Dotless root path with trailing '/' -> directory prefix (explicit override)
let pq = build_path_query(&conn, "Makefile/", None).unwrap();
assert_eq!(pq.value, "Makefile/%");
assert!(pq.is_prefix);
}
#[test]
fn test_escape_like() {
assert_eq!(escape_like("normal/path"), "normal/path");
assert_eq!(escape_like("has_underscore"), "has\\_underscore");
assert_eq!(escape_like("has%percent"), "has\\%percent");
assert_eq!(escape_like("has\\backslash"), "has\\\\backslash");
}
#[test]
fn test_build_path_query_exact_does_not_escape() {
let conn = setup_test_db();
// '_' must NOT be escaped for exact match (=).
let pq = build_path_query(&conn, "README_with_underscore.md", None).unwrap();
assert_eq!(pq.value, "README_with_underscore.md");
assert!(!pq.is_prefix);
}
#[test]
fn test_path_flag_dotless_root_file_is_exact() {
let conn = setup_test_db();
// --path Makefile must produce an exact match, not Makefile/%
let pq = build_path_query(&conn, "Makefile", None).unwrap();
assert_eq!(pq.value, "Makefile");
assert!(!pq.is_prefix);
let pq = build_path_query(&conn, "Dockerfile", None).unwrap();
assert_eq!(pq.value, "Dockerfile");
assert!(!pq.is_prefix);
}
#[test]
fn test_expert_query() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_b",
"src/auth/login.rs",
"**suggestion**: use const",
);
insert_diffnote(
&conn,
2,
1,
1,
"reviewer_b",
"src/auth/login.rs",
"**question**: why?",
);
insert_diffnote(
&conn,
3,
1,
1,
"reviewer_c",
"src/auth/session.rs",
"looks good",
);
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 3); // author_a, reviewer_b, reviewer_c
assert_eq!(result.experts[0].username, "author_a"); // highest score (authorship dominates)
}
#[test]
fn test_workload_query() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_issue(&conn, 1, 1, 42, "someone_else");
insert_assignee(&conn, 1, "dev_a");
insert_mr(&conn, 1, 1, 100, "dev_a", "opened");
let result = query_workload(&conn, "dev_a", None, None, 20).unwrap();
assert_eq!(result.assigned_issues.len(), 1);
assert_eq!(result.authored_mrs.len(), 1);
}
#[test]
fn test_reviews_query() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_b",
"src/foo.rs",
"**suggestion**: refactor",
);
insert_diffnote(
&conn,
2,
1,
1,
"reviewer_b",
"src/bar.rs",
"**question**: why?",
);
insert_diffnote(&conn, 3, 1, 1, "reviewer_b", "src/baz.rs", "looks good");
let result = query_reviews(&conn, "reviewer_b", None, 0).unwrap();
assert_eq!(result.total_diffnotes, 3);
assert_eq!(result.categorized_count, 2);
assert_eq!(result.categories.len(), 2);
}
#[test]
fn test_active_query() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work");
// Second note by same participant -- note_count should be 2, participants still ["reviewer_b"]
insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up");
let result = query_active(&conn, None, 0, 20).unwrap();
assert_eq!(result.total_unresolved_in_window, 1);
assert_eq!(result.discussions.len(), 1);
assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]);
// This was a regression in iteration 4: note_count was counting participants, not notes
assert_eq!(result.discussions[0].note_count, 2);
assert!(result.discussions[0].discussion_id > 0);
}
#[test]
fn test_overlap_dual_roles() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
// User is both author of one MR and reviewer of another at same path
insert_mr(&conn, 1, 1, 100, "dual_user", "opened");
insert_mr(&conn, 2, 1, 200, "other_author", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_discussion(&conn, 2, 1, Some(2), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"someone",
"src/auth/login.rs",
"review of dual_user's MR",
);
insert_diffnote(
&conn,
2,
2,
1,
"dual_user",
"src/auth/login.rs",
"dual_user reviewing other MR",
);
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
let dual = result
.users
.iter()
.find(|u| u.username == "dual_user")
.unwrap();
assert!(dual.author_touch_count > 0);
assert!(dual.review_touch_count > 0);
assert_eq!(format_overlap_role(dual), "A+R");
// MR refs should be project-qualified
assert!(dual.mr_refs.iter().any(|r| r.contains("team/backend!")));
}
#[test]
fn test_overlap_multi_project_mr_refs() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_project(&conn, 2, "team/frontend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_discussion(&conn, 2, 2, Some(2), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review");
insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review");
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
let reviewer = result
.users
.iter()
.find(|u| u.username == "reviewer_x")
.unwrap();
// Should have two distinct refs despite same iid
assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string()));
assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string()));
}
#[test]
fn test_normalize_review_prefix() {
assert_eq!(normalize_review_prefix("suggestion"), "suggestion");
assert_eq!(normalize_review_prefix("Suggestion:"), "suggestion");
assert_eq!(
normalize_review_prefix("suggestion (non-blocking):"),
"suggestion"
);
assert_eq!(normalize_review_prefix("Nitpick:"), "nit");
assert_eq!(normalize_review_prefix("nit (non-blocking):"), "nit");
assert_eq!(normalize_review_prefix("question"), "question");
assert_eq!(normalize_review_prefix("TODO:"), "todo");
}
#[test]
fn test_normalize_repo_path() {
// Strips leading ./
assert_eq!(normalize_repo_path("./src/foo/"), "src/foo/");
// Strips leading /
assert_eq!(normalize_repo_path("/src/foo/"), "src/foo/");
// Strips leading ./ recursively
assert_eq!(normalize_repo_path("././src/foo"), "src/foo");
// Converts Windows backslashes when no forward slashes
assert_eq!(normalize_repo_path("src\\foo\\bar.rs"), "src/foo/bar.rs");
// Does NOT convert backslashes when forward slashes present
assert_eq!(normalize_repo_path("src/foo\\bar"), "src/foo\\bar");
// Collapses repeated //
assert_eq!(normalize_repo_path("src//foo//bar/"), "src/foo/bar/");
// Trims whitespace
assert_eq!(normalize_repo_path(" src/foo/ "), "src/foo/");
// Identity for clean paths
assert_eq!(normalize_repo_path("src/foo/bar.rs"), "src/foo/bar.rs");
}
#[test]
fn test_lookup_project_path() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
assert_eq!(lookup_project_path(&conn, 1).unwrap(), "team/backend");
}
#[test]
fn test_build_path_query_dotless_subdir_file_uses_db_probe() {
// Dotless file in subdirectory (src/Dockerfile) would normally be
// treated as a directory. The DB probe detects it's actually a file.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/Dockerfile", "note");
let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
assert_eq!(pq.value, "src/Dockerfile");
assert!(!pq.is_prefix);
// Same path without DB data -> falls through to prefix
let conn2 = setup_test_db();
let pq2 = build_path_query(&conn2, "src/Dockerfile", None).unwrap();
assert_eq!(pq2.value, "src/Dockerfile/%");
assert!(pq2.is_prefix);
}
#[test]
fn test_build_path_query_probe_is_project_scoped() {
// Path exists as a dotless file in project 1; project 2 should not
// treat it as an exact file unless it exists there too.
let conn = setup_test_db();
insert_project(&conn, 1, "team/a");
insert_project(&conn, 2, "team/b");
insert_mr(&conn, 1, 1, 10, "author_a", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "rev", "infra/Makefile", "note");
// Unscoped: finds exact match in project 1 -> exact
let pq_unscoped = build_path_query(&conn, "infra/Makefile", None).unwrap();
assert!(!pq_unscoped.is_prefix);
// Scoped to project 2: no data -> falls back to prefix
let pq_scoped = build_path_query(&conn, "infra/Makefile", Some(2)).unwrap();
assert!(pq_scoped.is_prefix);
// Scoped to project 1: finds data -> exact
let pq_scoped1 = build_path_query(&conn, "infra/Makefile", Some(1)).unwrap();
assert!(!pq_scoped1.is_prefix);
}
#[test]
fn test_expert_excludes_self_review_notes() {
// MR author commenting on their own diff should not be counted as reviewer
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
// author_a comments on their own MR diff (clarification)
insert_diffnote(
&conn,
1,
1,
1,
"author_a",
"src/auth/login.rs",
"clarification",
);
// reviewer_b also reviews
insert_diffnote(
&conn,
2,
1,
1,
"reviewer_b",
"src/auth/login.rs",
"looks good",
);
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
// author_a should appear as author only, not as reviewer
let author = result
.experts
.iter()
.find(|e| e.username == "author_a")
.unwrap();
assert_eq!(author.review_mr_count, 0);
assert!(author.author_mr_count > 0);
// reviewer_b should be a reviewer
let reviewer = result
.experts
.iter()
.find(|e| e.username == "reviewer_b")
.unwrap();
assert!(reviewer.review_mr_count > 0);
}
#[test]
fn test_overlap_excludes_self_review_notes() {
// MR author commenting on their own diff should not inflate reviewer counts
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
// author_a comments on their own MR diff (clarification)
insert_diffnote(
&conn,
1,
1,
1,
"author_a",
"src/auth/login.rs",
"clarification",
);
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
let u = result.users.iter().find(|u| u.username == "author_a");
// Should NOT be credited as reviewer touch
assert!(u.map_or(0, |x| x.review_touch_count) == 0);
}
#[test]
fn test_active_participants_sorted() {
// Participants should be sorted alphabetically for deterministic output
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "zebra_user", "src/foo.rs", "note 1");
insert_diffnote(&conn, 2, 1, 1, "alpha_user", "src/foo.rs", "note 2");
let result = query_active(&conn, None, 0, 20).unwrap();
assert_eq!(
result.discussions[0].participants,
vec!["alpha_user", "zebra_user"]
);
}
#[test]
fn test_expert_truncation() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
// Create 3 experts
for i in 1..=3 {
insert_mr(&conn, i, 1, 100 + i, &format!("author_{i}"), "opened");
insert_discussion(&conn, i, 1, Some(i), None, true, false);
insert_diffnote(
&conn,
i,
i,
1,
&format!("reviewer_{i}"),
"src/auth/login.rs",
"note",
);
}
// limit = 2, should return truncated = true
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
2,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert!(result.truncated);
assert_eq!(result.experts.len(), 2);
// limit = 10, should return truncated = false
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
10,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert!(!result.truncated);
}
#[test]
fn test_expert_file_changes_only() {
// MR author should appear even when there are zero DiffNotes
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "file_author", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
let result = query_expert(
&conn,
"src/auth/login.rs",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 1);
assert_eq!(result.experts[0].username, "file_author");
assert!(result.experts[0].author_mr_count > 0);
assert_eq!(result.experts[0].review_mr_count, 0);
}
#[test]
fn test_expert_mr_reviewer_via_file_changes() {
// A reviewer assigned via mr_reviewers should appear when that MR
// touched the queried file (via mr_file_changes)
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_reviewer(&conn, 1, "assigned_reviewer");
let result = query_expert(
&conn,
"src/auth/login.rs",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
let reviewer = result
.experts
.iter()
.find(|e| e.username == "assigned_reviewer");
assert!(reviewer.is_some(), "assigned_reviewer should appear");
assert!(reviewer.unwrap().review_mr_count > 0);
}
#[test]
fn test_expert_deduplicates_across_signals() {
// User who is BOTH a DiffNote reviewer AND an mr_reviewers entry for
// the same MR should be counted only once per MR
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_b",
"src/auth/login.rs",
"looks good",
);
// Same user also listed as assigned reviewer, with file change data
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_reviewer(&conn, 1, "reviewer_b");
let result = query_expert(
&conn,
"src/auth/login.rs",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
let reviewer = result
.experts
.iter()
.find(|e| e.username == "reviewer_b")
.unwrap();
// Should be 1 MR, not 2 (dedup across DiffNote + mr_reviewers)
assert_eq!(reviewer.review_mr_count, 1);
}
#[test]
fn test_expert_combined_diffnote_and_file_changes() {
// Author with DiffNotes on path A and file_changes on path B should
// get credit for both when queried with a directory prefix
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
// MR 1: has DiffNotes on login.rs
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note");
// MR 2: has file_changes on session.rs (no DiffNotes)
insert_mr(&conn, 2, 1, 200, "author_a", "merged");
insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added");
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
let author = result
.experts
.iter()
.find(|e| e.username == "author_a")
.unwrap();
// Should count 2 authored MRs (one from DiffNote path, one from file changes)
assert_eq!(author.author_mr_count, 2);
}
#[test]
fn test_expert_file_changes_prefix_match() {
// Directory prefix queries should pick up mr_file_changes under the directory
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added");
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert_eq!(result.path_match, "prefix");
assert_eq!(result.experts.len(), 1);
assert_eq!(result.experts[0].username, "author_a");
}
#[test]
fn test_overlap_file_changes_only() {
// Overlap mode should also find users via mr_file_changes
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_reviewer(&conn, 1, "reviewer_x");
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
assert!(
result.users.iter().any(|u| u.username == "author_a"),
"author_a should appear via file_changes"
);
assert!(
result.users.iter().any(|u| u.username == "reviewer_x"),
"reviewer_x should appear via mr_reviewers + file_changes"
);
}
#[test]
fn test_build_path_query_resolves_via_file_changes() {
// DB probe should detect exact file match from mr_file_changes even
// when no DiffNotes exist for the path
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/Dockerfile", "modified");
let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap();
assert_eq!(pq.value, "src/Dockerfile");
assert!(!pq.is_prefix);
}
#[test]
fn test_expert_excludes_self_assigned_reviewer() {
// MR author listed in mr_reviewers for their own MR should NOT be
// counted as a reviewer (same principle as DiffNote self-review exclusion)
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
// author_a is self-assigned as reviewer
insert_reviewer(&conn, 1, "author_a");
// real_reviewer is also assigned
insert_reviewer(&conn, 1, "real_reviewer");
let result = query_expert(
&conn,
"src/auth/login.rs",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
// author_a should appear as author only, not reviewer
let author = result
.experts
.iter()
.find(|e| e.username == "author_a")
.unwrap();
assert_eq!(author.review_mr_count, 0);
assert!(author.author_mr_count > 0);
// real_reviewer should appear as reviewer
let reviewer = result
.experts
.iter()
.find(|e| e.username == "real_reviewer")
.unwrap();
assert!(reviewer.review_mr_count > 0);
}
#[test]
fn test_overlap_excludes_self_assigned_reviewer() {
// Same self-review exclusion for overlap mode via file changes
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_reviewer(&conn, 1, "author_a"); // self-assigned
let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap();
let user = result.users.iter().find(|u| u.username == "author_a");
// Should appear (as author) but NOT have reviewer touch count
assert!(user.is_some());
assert_eq!(user.unwrap().review_touch_count, 0);
}
// ─── Suffix / Fuzzy Path Resolution Tests ───────────────────────────────
#[test]
fn test_build_path_query_suffix_resolves_bare_filename() {
// User types just "login.rs" but the DB has "src/auth/login.rs"
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
let pq = build_path_query(&conn, "login.rs", None).unwrap();
assert_eq!(pq.value, "src/auth/login.rs");
assert!(!pq.is_prefix);
}
#[test]
fn test_build_path_query_suffix_resolves_partial_path() {
// User types "auth/login.rs" but full path is "src/auth/login.rs"
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
let pq = build_path_query(&conn, "auth/login.rs", None).unwrap();
assert_eq!(pq.value, "src/auth/login.rs");
assert!(!pq.is_prefix);
}
#[test]
fn test_build_path_query_suffix_ambiguous_returns_error() {
// Two different files share the same filename -> Ambiguous error
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/utils.rs", "modified");
insert_file_change(&conn, 1, 1, "src/db/utils.rs", "modified");
let err = build_path_query(&conn, "utils.rs", None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("src/auth/utils.rs"),
"should list candidates: {msg}"
);
assert!(
msg.contains("src/db/utils.rs"),
"should list candidates: {msg}"
);
}
#[test]
fn test_build_path_query_suffix_scoped_to_project() {
// Two projects have the same filename; scoping to one should resolve
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_project(&conn, 2, "team/frontend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_mr(&conn, 2, 2, 200, "author_b", "merged");
insert_file_change(&conn, 1, 1, "src/utils.rs", "modified");
insert_file_change(&conn, 2, 2, "lib/utils.rs", "modified");
// Unscoped -> ambiguous
assert!(build_path_query(&conn, "utils.rs", None).is_err());
// Scoped to project 1 -> resolves
let pq = build_path_query(&conn, "utils.rs", Some(1)).unwrap();
assert_eq!(pq.value, "src/utils.rs");
assert!(!pq.is_prefix);
}
#[test]
fn test_build_path_query_suffix_deduplicates_across_sources() {
// Same path in both notes AND mr_file_changes -> single unique match, not ambiguous
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_a",
"src/auth/login.rs",
"review note",
);
let pq = build_path_query(&conn, "login.rs", None).unwrap();
assert_eq!(pq.value, "src/auth/login.rs");
assert!(!pq.is_prefix);
}
#[test]
fn test_build_path_query_exact_match_still_preferred() {
// If the exact path exists in the DB, suffix should NOT be attempted
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "author_a", "merged");
insert_file_change(&conn, 1, 1, "README.md", "modified");
insert_file_change(&conn, 1, 1, "docs/README.md", "modified");
// "README.md" exists as exact match -> use it directly, no ambiguity
let pq = build_path_query(&conn, "README.md", None).unwrap();
assert_eq!(pq.value, "README.md");
assert!(!pq.is_prefix);
}
#[test]
fn test_expert_scoring_weights_are_configurable() {
// With reviewer-heavy weights, reviewer should rank above author.
// With author-heavy weights (default), author should rank above reviewer.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "the_author", "merged");
insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"the_reviewer",
"src/app.rs",
"lgtm -- a substantive review comment",
);
insert_reviewer(&conn, 1, "the_reviewer");
// Default weights: author=25, reviewer=10 → author wins
let result = query_expert(
&conn,
"src/app.rs",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts[0].username, "the_author");
// Custom weights: flip so reviewer dominates
let flipped = ScoringConfig {
author_weight: 5,
reviewer_weight: 30,
note_bonus: 1,
..Default::default()
};
let result = query_expert(
&conn,
"src/app.rs",
None,
0,
test_as_of_ms(),
20,
&flipped,
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts[0].username, "the_reviewer");
}
#[test]
fn test_expert_mr_refs() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 891, "author_a", "merged");
insert_mr(&conn, 2, 1, 847, "author_a", "merged");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_discussion(&conn, 2, 1, Some(2), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1");
insert_diffnote(&conn, 2, 2, 1, "reviewer_b", "src/auth/login.rs", "note2");
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
// reviewer_b should have MR refs
let reviewer = result
.experts
.iter()
.find(|e| e.username == "reviewer_b")
.unwrap();
assert!(reviewer.mr_refs.contains(&"team/backend!847".to_string()));
assert!(reviewer.mr_refs.contains(&"team/backend!891".to_string()));
assert_eq!(reviewer.mr_refs_total, 2);
assert!(!reviewer.mr_refs_truncated);
// author_a should also have MR refs
let author = result
.experts
.iter()
.find(|e| e.username == "author_a")
.unwrap();
assert!(author.mr_refs.contains(&"team/backend!847".to_string()));
assert!(author.mr_refs.contains(&"team/backend!891".to_string()));
assert_eq!(author.mr_refs_total, 2);
}
#[test]
fn test_expert_mr_refs_multi_project() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_project(&conn, 2, "team/frontend");
insert_mr(&conn, 1, 1, 100, "author_a", "opened");
insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_discussion(&conn, 2, 2, Some(2), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review");
insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review");
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
let reviewer = result
.experts
.iter()
.find(|e| e.username == "reviewer_x")
.unwrap();
// Should have two distinct refs despite same iid
assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string()));
assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string()));
assert_eq!(reviewer.mr_refs_total, 2);
}
#[test]
fn test_half_life_decay_math() {
// elapsed=0 -> 1.0 (no decay)
assert!((half_life_decay(0, 180) - 1.0).abs() < f64::EPSILON);
// elapsed=half_life -> 0.5
let half_life_ms = 180 * 86_400_000_i64;
assert!((half_life_decay(half_life_ms, 180) - 0.5).abs() < 1e-10);
// elapsed=2*half_life -> 0.25
assert!((half_life_decay(2 * half_life_ms, 180) - 0.25).abs() < 1e-10);
// half_life_days=0 -> 0.0 (guard against div-by-zero)
assert!((half_life_decay(1_000_000, 0)).abs() < f64::EPSILON);
// negative elapsed clamped to 0 -> 1.0
assert!((half_life_decay(-5_000_000, 180) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_path_normalization_handles_dot_and_double_slash() {
assert_eq!(normalize_repo_path("./src//foo.rs"), "src/foo.rs");
assert_eq!(normalize_repo_path(" src/bar.rs "), "src/bar.rs");
assert_eq!(normalize_repo_path("src/foo.rs"), "src/foo.rs");
assert_eq!(normalize_repo_path(""), "");
}
#[test]
fn test_path_normalization_preserves_prefix_semantics() {
// Trailing slash preserved for prefix intent
assert_eq!(normalize_repo_path("./src/dir/"), "src/dir/");
// No trailing slash = file, not prefix
assert_eq!(normalize_repo_path("src/dir"), "src/dir");
}
#[test]
fn test_expert_sql_v2_prepares_exact() {
let conn = setup_test_db();
let sql = build_expert_sql_v2(false);
// Verify the SQL is syntactically valid and INDEXED BY references exist
conn.prepare_cached(&sql)
.expect("v2 exact SQL should parse");
}
#[test]
fn test_expert_sql_v2_prepares_prefix() {
let conn = setup_test_db();
let sql = build_expert_sql_v2(true);
conn.prepare_cached(&sql)
.expect("v2 prefix SQL should parse");
}
#[test]
fn test_expert_sql_v2_returns_signals() {
let conn = setup_test_db();
let now = now_ms();
insert_project(&conn, 1, "team/backend");
insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None);
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_b",
"src/auth/login.rs",
"substantive review comment here",
);
insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified");
insert_reviewer(&conn, 1, "reviewer_b");
let sql = build_expert_sql_v2(false);
let mut stmt = conn.prepare_cached(&sql).unwrap();
let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt
.query_map(
rusqlite::params![
"src/auth/login.rs", // ?1 path
0_i64, // ?2 since_ms
Option::<i64>::None, // ?3 project_id
now + 1000, // ?4 as_of_ms (slightly in future to include test data)
0.5_f64, // ?5 closed_mr_multiplier
20_i64, // ?6 reviewer_min_note_chars
],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, f64>(5)?,
))
},
)
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
// Should have signals for both author_a and reviewer_b
let usernames: Vec<&str> = rows.iter().map(|r| r.0.as_str()).collect();
assert!(usernames.contains(&"author_a"), "should contain author_a");
assert!(
usernames.contains(&"reviewer_b"),
"should contain reviewer_b"
);
// Verify signal types are from the expected set
let valid_signals = [
"diffnote_author",
"file_author",
"file_reviewer_participated",
"file_reviewer_assigned",
"note_group",
];
for row in &rows {
assert!(
valid_signals.contains(&row.1.as_str()),
"unexpected signal type: {}",
row.1
);
}
// state_mult should be 1.0 for merged MRs
for row in &rows {
assert!(
(row.5 - 1.0).abs() < f64::EPSILON,
"merged MR should have state_mult=1.0, got {}",
row.5
);
}
}
#[test]
fn test_expert_sql_v2_reviewer_split() {
let conn = setup_test_db();
let now = now_ms();
insert_project(&conn, 1, "team/backend");
insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None);
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
// reviewer_b leaves a substantive note (>= 20 chars)
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_b",
"src/app.rs",
"This looks correct, good refactoring work here",
);
insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
insert_reviewer(&conn, 1, "reviewer_b");
// reviewer_c is assigned but leaves no notes
insert_reviewer(&conn, 1, "reviewer_c");
let sql = build_expert_sql_v2(false);
let mut stmt = conn.prepare_cached(&sql).unwrap();
let rows: Vec<(String, String)> = stmt
.query_map(
rusqlite::params![
"src/app.rs",
0_i64,
Option::<i64>::None,
now + 1000,
0.5_f64,
20_i64,
],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
// reviewer_b should be "file_reviewer_participated"
let b_signals: Vec<&str> = rows
.iter()
.filter(|r| r.0 == "reviewer_b")
.map(|r| r.1.as_str())
.collect();
assert!(
b_signals.contains(&"file_reviewer_participated"),
"reviewer_b should be participated, got: {:?}",
b_signals
);
assert!(
!b_signals.contains(&"file_reviewer_assigned"),
"reviewer_b should NOT be assigned-only"
);
// reviewer_c should be "file_reviewer_assigned"
let c_signals: Vec<&str> = rows
.iter()
.filter(|r| r.0 == "reviewer_c")
.map(|r| r.1.as_str())
.collect();
assert!(
c_signals.contains(&"file_reviewer_assigned"),
"reviewer_c should be assigned-only, got: {:?}",
c_signals
);
assert!(
!c_signals.contains(&"file_reviewer_participated"),
"reviewer_c should NOT be participated"
);
}
#[test]
fn test_expert_sql_v2_closed_mr_multiplier() {
let conn = setup_test_db();
let now = now_ms();
insert_project(&conn, 1, "team/backend");
insert_mr_at(&conn, 1, 1, 100, "author_a", "closed", now, None, Some(now));
insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
let sql = build_expert_sql_v2(false);
let mut stmt = conn.prepare_cached(&sql).unwrap();
let rows: Vec<(String, String, f64)> = stmt
.query_map(
rusqlite::params![
"src/app.rs",
0_i64,
Option::<i64>::None,
now + 1000,
0.5_f64,
20_i64,
],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, f64>(5)?,
))
},
)
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
// All signals from closed MR should have state_mult=0.5
for row in &rows {
assert!(
(row.2 - 0.5).abs() < f64::EPSILON,
"closed MR should have state_mult=0.5, got {} for signal {}",
row.2,
row.1
);
}
}
#[test]
fn test_expert_v2_decay_scoring() {
let conn = setup_test_db();
let now = now_ms();
let day_ms = 86_400_000_i64;
insert_project(&conn, 1, "team/backend");
// Recent author: 10 days ago
insert_mr_at(
&conn,
1,
1,
100,
"recent_author",
"merged",
now - 10 * day_ms,
Some(now - 10 * day_ms),
None,
);
insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
// Old author: 360 days ago
insert_mr_at(
&conn,
2,
1,
101,
"old_author",
"merged",
now - 360 * day_ms,
Some(now - 360 * day_ms),
None,
);
insert_file_change(&conn, 2, 1, "src/app.rs", "modified");
let scoring = default_scoring();
let result = query_expert(
&conn,
"src/app.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 2);
// Recent author should rank first
assert_eq!(result.experts[0].username, "recent_author");
assert_eq!(result.experts[1].username, "old_author");
// Recent author score should be much higher than old author
assert!(result.experts[0].score > result.experts[1].score);
}
#[test]
fn test_expert_v2_reviewer_split_scoring() {
let conn = setup_test_db();
let now = now_ms();
let day_ms = 86_400_000_i64;
insert_project(&conn, 1, "team/backend");
// MR from 30 days ago
insert_mr_at(
&conn,
1,
1,
100,
"the_author",
"merged",
now - 30 * day_ms,
Some(now - 30 * day_ms),
None,
);
insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
// Reviewer A: participated (left substantive DiffNotes)
insert_diffnote(
&conn,
1,
1,
1,
"participated_reviewer",
"src/app.rs",
"Substantive review comment here about the code",
);
insert_reviewer(&conn, 1, "participated_reviewer");
// Reviewer B: assigned only (no DiffNotes)
insert_reviewer(&conn, 1, "assigned_reviewer");
let scoring = default_scoring();
let result = query_expert(
&conn,
"src/app.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
false,
false,
)
.unwrap();
let participated = result
.experts
.iter()
.find(|e| e.username == "participated_reviewer");
let assigned = result
.experts
.iter()
.find(|e| e.username == "assigned_reviewer");
assert!(
participated.is_some(),
"participated reviewer should appear"
);
assert!(assigned.is_some(), "assigned reviewer should appear");
// Participated reviewer should score higher (weight=10 vs weight=3)
assert!(
participated.unwrap().score > assigned.unwrap().score,
"participated ({}) should score higher than assigned ({})",
participated.unwrap().score,
assigned.unwrap().score
);
}
#[test]
fn test_expert_v2_excluded_usernames() {
let conn = setup_test_db();
let now = now_ms();
insert_project(&conn, 1, "team/backend");
insert_mr_at(
&conn,
1,
1,
100,
"real_user",
"merged",
now,
Some(now),
None,
);
insert_mr_at(
&conn,
2,
1,
101,
"renovate-bot",
"merged",
now,
Some(now),
None,
);
insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
insert_file_change(&conn, 2, 1, "src/app.rs", "modified");
let mut scoring = default_scoring();
scoring.excluded_usernames = vec!["renovate-bot".to_string()];
let result = query_expert(
&conn,
"src/app.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
false,
false,
)
.unwrap();
let usernames: Vec<&str> = result.experts.iter().map(|e| e.username.as_str()).collect();
assert!(usernames.contains(&"real_user"));
assert!(
!usernames.contains(&"renovate-bot"),
"bot should be excluded"
);
}
#[test]
fn test_expert_v2_deterministic_ordering() {
let conn = setup_test_db();
let now = now_ms();
let day_ms = 86_400_000_i64;
insert_project(&conn, 1, "team/backend");
// Create 5 MRs with varied timestamps
for i in 0_i64..5 {
insert_mr_at(
&conn,
i + 1,
1,
100 + i,
"test_user",
"merged",
now - (i + 1) * 30 * day_ms,
Some(now - (i + 1) * 30 * day_ms),
None,
);
insert_file_change(&conn, i + 1, 1, "src/app.rs", "modified");
}
let scoring = default_scoring();
// Run 10 times and verify identical scores
let mut scores: Vec<i64> = Vec::new();
for _ in 0..10 {
let result = query_expert(
&conn,
"src/app.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 1);
scores.push(result.experts[0].score);
}
assert!(
scores.windows(2).all(|w| w[0] == w[1]),
"scores should be identical across runs: {:?}",
scores
);
}
#[test]
fn test_expert_detail_mode() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 891, "author_a", "merged");
insert_mr(&conn, 2, 1, 902, "author_a", "merged");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_discussion(&conn, 2, 1, Some(2), None, true, false);
insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1");
insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/auth/login.rs", "note2");
insert_diffnote(&conn, 3, 2, 1, "reviewer_b", "src/auth/session.rs", "note3");
// Without detail: details should be None
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
for expert in &result.experts {
assert!(expert.details.is_none());
}
// With detail: details should be populated
let result = query_expert(
&conn,
"src/auth/",
None,
0,
test_as_of_ms(),
20,
&default_scoring(),
true,
false,
false,
)
.unwrap();
let reviewer = result
.experts
.iter()
.find(|e| e.username == "reviewer_b")
.unwrap();
let details = reviewer.details.as_ref().unwrap();
assert!(!details.is_empty());
// All detail entries should have role "R" for reviewer
for d in details {
assert!(
d.role == "R" || d.role == "A+R",
"role should be R or A+R, got {}",
d.role
);
assert!(d.mr_ref.starts_with("team/backend!"));
}
// author_a should have detail entries with role "A"
let author = result
.experts
.iter()
.find(|e| e.username == "author_a")
.unwrap();
let author_details = author.details.as_ref().unwrap();
assert!(!author_details.is_empty());
for d in author_details {
assert!(
d.role == "A" || d.role == "A+R",
"role should be A or A+R, got {}",
d.role
);
}
}
#[test]
fn test_old_path_probe_exact_and_prefix() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "alice", "merged");
insert_file_change_with_old_path(
&conn,
1,
1,
"src/new/foo.rs",
Some("src/old/foo.rs"),
"renamed",
);
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote_at(
&conn,
1,
1,
1,
"alice",
"src/new/foo.rs",
Some("src/old/foo.rs"),
"review comment",
now_ms(),
);
// Exact probe by OLD path should resolve
let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap();
assert!(!pq.is_prefix, "old exact path should resolve as exact");
assert_eq!(pq.value, "src/old/foo.rs");
// Prefix probe by OLD directory should resolve
let pq = build_path_query(&conn, "src/old/", None).unwrap();
assert!(pq.is_prefix, "old directory should resolve as prefix");
// New path still works
let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap();
assert!(!pq.is_prefix, "new exact path should still resolve");
assert_eq!(pq.value, "src/new/foo.rs");
}
#[test]
fn test_suffix_probe_uses_old_path_sources() {
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
insert_mr(&conn, 1, 1, 100, "alice", "merged");
insert_file_change_with_old_path(
&conn,
1,
1,
"src/utils.rs",
Some("legacy/utils.rs"),
"renamed",
);
let result = suffix_probe(&conn, "utils.rs", None).unwrap();
match result {
SuffixResult::Ambiguous(paths) => {
assert!(
paths.contains(&"src/utils.rs".to_string()),
"should contain new path"
);
assert!(
paths.contains(&"legacy/utils.rs".to_string()),
"should contain old path"
);
}
SuffixResult::Unique(p) => {
panic!("Expected Ambiguous with both paths, got Unique({p})");
}
SuffixResult::NoMatch => panic!("Expected Ambiguous, got NoMatch"),
SuffixResult::NotAttempted => panic!("Expected Ambiguous, got NotAttempted"),
}
}
// ─── Plan Section 8: New Tests ──────────────────────────────────────────────
#[test]
fn test_expert_scores_decay_with_time() {
// Two authors, one recent (10 days), one old (360 days).
// With default author_half_life_days=180, recent ≈ 24.1, old ≈ 6.3.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
let recent_ts = now - 10 * day_ms; // 10 days ago
let old_ts = now - 360 * day_ms; // 360 days ago
// Recent author
insert_mr_at(
&conn,
1,
1,
100,
"recent_author",
"merged",
recent_ts,
Some(recent_ts),
None,
);
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
// Old author
insert_mr_at(
&conn,
2,
1,
200,
"old_author",
"merged",
old_ts,
Some(old_ts),
None,
);
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 2);
assert_eq!(result.experts[0].username, "recent_author");
assert_eq!(result.experts[1].username, "old_author");
// Recent author scores significantly higher
let recent_score = result.experts[0].score_raw.unwrap();
let old_score = result.experts[1].score_raw.unwrap();
assert!(
recent_score > old_score * 2.0,
"recent ({recent_score:.1}) should be >2x old ({old_score:.1})"
);
}
#[test]
fn test_expert_reviewer_decays_faster_than_author() {
// Same MR, same age (90 days). Author half-life=180d, reviewer half-life=90d.
// Author retains 2^(-90/180)=0.707 of weight, reviewer retains 2^(-90/90)=0.5.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
let age_ts = now - 90 * day_ms;
insert_mr_at(
&conn,
1,
1,
100,
"the_author",
"merged",
age_ts,
Some(age_ts),
None,
);
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote_at(
&conn,
1,
1,
1,
"the_reviewer",
"src/lib.rs",
None,
"a substantive review comment here",
age_ts,
);
insert_reviewer(&conn, 1, "the_reviewer");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
// Author gets file_author (25*0.707) + diffnote_author (25*0.707) ≈ 35.4
// Reviewer gets file_reviewer_participated (10*0.5) + note_group (1*1.0*0.5) ≈ 5.5
assert_eq!(result.experts[0].username, "the_author");
let author_score = result.experts[0].score_raw.unwrap();
let reviewer_score = result.experts[1].score_raw.unwrap();
assert!(
author_score > reviewer_score * 3.0,
"author ({author_score:.1}) should dominate reviewer ({reviewer_score:.1})"
);
}
#[test]
fn test_reviewer_participated_vs_assigned_only() {
// Two reviewers on the same MR. One left substantive DiffNotes (participated),
// one didn't (assigned-only). Participated gets reviewer_weight, assigned-only
// gets reviewer_assignment_weight (much lower).
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "the_author", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"active_reviewer",
"src/lib.rs",
"This needs refactoring because...",
);
insert_reviewer(&conn, 1, "active_reviewer");
insert_reviewer(&conn, 1, "passive_reviewer");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
let active = result
.experts
.iter()
.find(|e| e.username == "active_reviewer")
.unwrap();
let passive = result
.experts
.iter()
.find(|e| e.username == "passive_reviewer")
.unwrap();
let active_score = active.score_raw.unwrap();
let passive_score = passive.score_raw.unwrap();
// Default: reviewer_weight=10, reviewer_assignment_weight=3
// Active: 10 * ~1.0 + note_group ≈ 11
// Passive: 3 * ~1.0 = 3
assert!(
active_score > passive_score * 2.0,
"active ({active_score:.1}) should be >2x passive ({passive_score:.1})"
);
}
#[test]
fn test_note_diminishing_returns_per_mr() {
// One reviewer with 1 note on MR-A and another with 20 notes on MR-B.
// The 20-note reviewer should score ~log2(21)/log2(2) ≈ 4.4x, NOT 20x.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
// MR 1 with 1 note
insert_mr(&conn, 1, 1, 100, "author_x", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"one_note_reviewer",
"src/lib.rs",
"a single substantive review note",
);
insert_reviewer(&conn, 1, "one_note_reviewer");
// MR 2 with 20 notes from another reviewer
insert_mr(&conn, 2, 1, 200, "author_y", "merged");
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 2, 1, Some(2), None, true, false);
for i in 0_i64..20 {
insert_diffnote(
&conn,
100 + i,
2,
1,
"many_note_reviewer",
"src/lib.rs",
&format!("substantive review comment number {i}"),
);
}
insert_reviewer(&conn, 2, "many_note_reviewer");
let scoring = ScoringConfig {
reviewer_weight: 0,
reviewer_assignment_weight: 0,
author_weight: 0,
note_bonus: 10, // High bonus to isolate note contribution
..Default::default()
};
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
true,
false,
)
.unwrap();
let one = result
.experts
.iter()
.find(|e| e.username == "one_note_reviewer")
.unwrap();
let many = result
.experts
.iter()
.find(|e| e.username == "many_note_reviewer")
.unwrap();
let one_score = one.score_raw.unwrap();
let many_score = many.score_raw.unwrap();
// log2(1+1)=1.0, log2(1+20)≈4.39. Ratio should be ~4.4x, not 20x.
let ratio = many_score / one_score;
assert!(
ratio < 6.0,
"ratio ({ratio:.1}) should be ~4.4, not 20 (diminishing returns)"
);
assert!(
ratio > 3.0,
"ratio ({ratio:.1}) should be ~4.4, reflecting log2 scaling"
);
}
#[test]
fn test_file_change_timestamp_uses_merged_at() {
// A merged MR should use merged_at for decay, not updated_at.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
let old_merged = now - 300 * day_ms; // merged 300 days ago
let recent_updated = now - day_ms; // updated yesterday
// MR merged long ago but recently updated (e.g., label change)
insert_mr_at(
&conn,
1,
1,
100,
"the_author",
"merged",
recent_updated,
Some(old_merged),
None,
);
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 1);
let score = result.experts[0].score_raw.unwrap();
// With half_life=180d and elapsed=300d, decay = 2^(-300/180) ≈ 0.315
// Score ≈ 25 * 0.315 ≈ 7.9 (file_author only, no diffnote_author without notes)
// If it incorrectly used updated_at (1 day), score ≈ 25 * ~1.0 = 25
assert!(
score < 15.0,
"score ({score:.1}) should reflect old merged_at, not recent updated_at"
);
}
#[test]
fn test_open_mr_uses_updated_at() {
// An opened MR should use updated_at for decay, not created_at.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
// MR-A: opened, recently updated (decay ≈ 1.0)
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at)
VALUES (1, 10, 1, 100, 'MR 100', 'recent_update', 'opened', ?1, ?2, ?3)",
rusqlite::params![now, now - 5 * day_ms, now - 200 * day_ms],
).unwrap();
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
// MR-B: opened, old updated_at (decay significant)
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at)
VALUES (2, 20, 1, 200, 'MR 200', 'old_update', 'opened', ?1, ?2, ?3)",
rusqlite::params![now, now - 200 * day_ms, now - 200 * day_ms],
).unwrap();
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
// recent_update should rank first (higher score from fresher updated_at)
assert_eq!(result.experts[0].username, "recent_update");
let recent = result.experts[0].score_raw.unwrap();
let old = result.experts[1].score_raw.unwrap();
assert!(
recent > old * 2.0,
"recent ({recent:.1}) should beat old ({old:.1}) by updated_at"
);
}
#[test]
fn test_old_path_match_credits_expertise() {
// DiffNote with old_path should credit expertise when queried by old path.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "the_author", "merged");
insert_file_change_with_old_path(&conn, 1, 1, "src/new.rs", Some("src/old.rs"), "renamed");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote_at(
&conn,
1,
1,
1,
"reviewer_a",
"src/new.rs",
Some("src/old.rs"),
"substantive review of the renamed file",
now,
);
// Query by old path
let result_old = query_expert(
&conn,
"src/old.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
// Query by new path
let result_new = query_expert(
&conn,
"src/new.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
// Both queries should find the author
assert!(
result_old
.experts
.iter()
.any(|e| e.username == "the_author"),
"author should appear via old_path query"
);
assert!(
result_new
.experts
.iter()
.any(|e| e.username == "the_author"),
"author should appear via new_path query"
);
}
#[test]
fn test_explain_score_components_sum_to_total() {
// With explain_score, component subtotals should sum to score_raw.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "the_author", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
insert_diffnote(
&conn,
1,
1,
1,
"the_reviewer",
"src/lib.rs",
"a substantive enough review comment",
);
insert_reviewer(&conn, 1, "the_reviewer");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
for expert in &result.experts {
let raw = expert.score_raw.unwrap();
let comp = expert.components.as_ref().unwrap();
let sum = comp.author + comp.reviewer_participated + comp.reviewer_assigned + comp.notes;
assert!(
(raw - sum).abs() < 1e-10,
"components ({sum:.6}) should sum to score_raw ({raw:.6}) for {}",
expert.username
);
}
}
#[test]
fn test_as_of_produces_deterministic_results() {
// Same as_of value produces identical results across multiple runs.
// Later as_of produces lower scores (more decay).
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
let mr_ts = now - 30 * day_ms;
insert_mr_at(
&conn,
1,
1,
100,
"the_author",
"merged",
mr_ts,
Some(mr_ts),
None,
);
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
let as_of_early = now;
let as_of_late = now + 180 * day_ms;
let result1 = query_expert(
&conn,
"src/lib.rs",
None,
0,
as_of_early,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
let result2 = query_expert(
&conn,
"src/lib.rs",
None,
0,
as_of_early,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
// Same as_of -> identical scores
assert_eq!(
result1.experts[0].score_raw.unwrap().to_bits(),
result2.experts[0].score_raw.unwrap().to_bits(),
"same as_of should produce bit-identical scores"
);
// Later as_of -> lower score
let result_late = query_expert(
&conn,
"src/lib.rs",
None,
0,
as_of_late,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
assert!(
result1.experts[0].score_raw.unwrap() > result_late.experts[0].score_raw.unwrap(),
"later as_of should produce lower scores"
);
}
#[test]
fn test_trivial_note_does_not_count_as_participation() {
// A reviewer with only a short note ("LGTM") should be classified as
// assigned-only, not participated, when reviewer_min_note_chars = 20.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "the_author", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
// Short note (4 chars, below threshold of 20)
insert_diffnote(&conn, 1, 1, 1, "trivial_reviewer", "src/lib.rs", "LGTM");
insert_reviewer(&conn, 1, "trivial_reviewer");
// Another reviewer with substantive note
insert_diffnote(
&conn,
2,
1,
1,
"substantive_reviewer",
"src/lib.rs",
"This function needs better error handling for the edge case...",
);
insert_reviewer(&conn, 1, "substantive_reviewer");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
let trivial = result
.experts
.iter()
.find(|e| e.username == "trivial_reviewer")
.unwrap();
let substantive = result
.experts
.iter()
.find(|e| e.username == "substantive_reviewer")
.unwrap();
let trivial_comp = trivial.components.as_ref().unwrap();
let substantive_comp = substantive.components.as_ref().unwrap();
// Trivial should get reviewer_assigned (3), not reviewer_participated (10)
assert!(
trivial_comp.reviewer_assigned > 0.0,
"trivial reviewer should get assigned-only signal"
);
assert!(
trivial_comp.reviewer_participated < 0.01,
"trivial reviewer should NOT get participated signal"
);
// Substantive should get reviewer_participated
assert!(
substantive_comp.reviewer_participated > 0.0,
"substantive reviewer should get participated signal"
);
}
#[test]
fn test_closed_mr_multiplier() {
// Two MRs with the same author: one merged, one closed.
// Closed should contribute author_weight * closed_mr_multiplier * decay.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
// Merged MR
insert_mr(&conn, 1, 1, 100, "merged_author", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
// Closed MR (same path, different author)
insert_mr(&conn, 2, 1, 200, "closed_author", "closed");
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
let merged = result
.experts
.iter()
.find(|e| e.username == "merged_author")
.unwrap();
let closed = result
.experts
.iter()
.find(|e| e.username == "closed_author")
.unwrap();
let merged_score = merged.score_raw.unwrap();
let closed_score = closed.score_raw.unwrap();
// Default closed_mr_multiplier=0.5, so closed should be roughly half
let ratio = closed_score / merged_score;
assert!(
(ratio - 0.5).abs() < 0.1,
"closed/merged ratio ({ratio:.2}) should be ≈0.5"
);
}
#[test]
fn test_as_of_excludes_future_events() {
// Events after as_of should not appear in results.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
let past_ts = now - 30 * day_ms;
let future_ts = now + 30 * day_ms;
// Past MR (should appear)
insert_mr_at(
&conn,
1,
1,
100,
"past_author",
"merged",
past_ts,
Some(past_ts),
None,
);
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
// Future MR (should NOT appear)
insert_mr_at(
&conn,
2,
1,
200,
"future_author",
"merged",
future_ts,
Some(future_ts),
None,
);
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now,
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert!(
result.experts.iter().any(|e| e.username == "past_author"),
"past author should appear"
);
assert!(
!result.experts.iter().any(|e| e.username == "future_author"),
"future author should be excluded by as_of"
);
}
#[test]
fn test_as_of_exclusive_upper_bound() {
// An event with timestamp exactly equal to as_of should be excluded (strict <).
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let boundary_ts = now;
insert_mr_at(
&conn,
1,
1,
100,
"boundary_author",
"merged",
boundary_ts,
Some(boundary_ts),
None,
);
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
boundary_ts,
20,
&default_scoring(),
false,
false,
false,
)
.unwrap();
assert!(
!result
.experts
.iter()
.any(|e| e.username == "boundary_author"),
"event at exactly as_of should be excluded (half-open interval)"
);
}
#[test]
fn test_excluded_usernames_filters_bots() {
// Bot users in excluded_usernames should be filtered out.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_mr(&conn, 2, 1, 200, "jsmith", "merged");
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
let scoring = ScoringConfig {
excluded_usernames: vec!["renovate-bot".to_string()],
..Default::default()
};
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
false,
false,
)
.unwrap();
assert_eq!(result.experts.len(), 1);
assert_eq!(result.experts[0].username, "jsmith");
}
#[test]
fn test_include_bots_flag_disables_filtering() {
// With include_bots=true, excluded_usernames should be ignored.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_mr(&conn, 2, 1, 200, "jsmith", "merged");
insert_file_change(&conn, 2, 1, "src/lib.rs", "modified");
let scoring = ScoringConfig {
excluded_usernames: vec!["renovate-bot".to_string()],
..Default::default()
};
// include_bots = true
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&scoring,
false,
false,
true,
)
.unwrap();
assert_eq!(result.experts.len(), 2);
assert!(result.experts.iter().any(|e| e.username == "renovate-bot"));
assert!(result.experts.iter().any(|e| e.username == "jsmith"));
}
#[test]
fn test_null_timestamp_fallback_to_created_at() {
// A merged MR with merged_at=NULL should fall back to created_at.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
let old_ts = now - 100 * day_ms;
// Insert merged MR with merged_at=NULL, created_at set
conn.execute(
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, created_at, merged_at, updated_at)
VALUES (1, 10, 1, 100, 'MR 100', 'the_author', 'merged', ?1, ?2, NULL, NULL)",
rusqlite::params![now, old_ts],
).unwrap();
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
// Should still find the author (not panic or return empty)
assert_eq!(result.experts.len(), 1);
assert_eq!(result.experts[0].username, "the_author");
// Score should reflect old_ts (100 days ago), not 0 or now
let score = result.experts[0].score_raw.unwrap();
// 25 * 2^(-100/180) ≈ 17.1
assert!(
score > 5.0 && score < 22.0,
"score ({score:.1}) should reflect created_at fallback"
);
}
// ─── Invariant Tests ────────────────────────────────────────────────────────
#[test]
fn test_score_monotonicity_by_age() {
// For any single signal, older timestamp must never produce a higher score.
for half_life in [1_u32, 7, 45, 90, 180, 365] {
let mut prev_decay = f64::MAX;
for days in 0..=730 {
let elapsed_ms = i64::from(days) * 86_400_000;
let decay = half_life_decay(elapsed_ms, half_life);
assert!(
decay <= prev_decay,
"monotonicity violated: half_life={half_life}, day={days}, decay={decay} > prev={prev_decay}"
);
prev_decay = decay;
}
}
}
#[test]
fn test_row_order_independence() {
// Same signals inserted in different order should produce identical results.
let conn1 = setup_test_db();
let conn2 = setup_test_db();
let now = now_ms();
let day_ms: i64 = 86_400_000;
// Setup identical data in both DBs but in different insertion order
for conn in [&conn1, &conn2] {
insert_project(conn, 1, "team/backend");
}
// Forward order in conn1
for i in 1_i64..=5 {
let ts = now - i * 30 * day_ms;
insert_mr_at(
&conn1,
i,
1,
i * 100,
&format!("author_{i}"),
"merged",
ts,
Some(ts),
None,
);
insert_file_change(&conn1, i, 1, "src/lib.rs", "modified");
}
// Reverse order in conn2
for i in (1_i64..=5).rev() {
let ts = now - i * 30 * day_ms;
insert_mr_at(
&conn2,
i,
1,
i * 100,
&format!("author_{i}"),
"merged",
ts,
Some(ts),
None,
);
insert_file_change(&conn2, i, 1, "src/lib.rs", "modified");
}
let r1 = query_expert(
&conn1,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
let r2 = query_expert(
&conn2,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
assert_eq!(r1.experts.len(), r2.experts.len());
for (e1, e2) in r1.experts.iter().zip(r2.experts.iter()) {
assert_eq!(e1.username, e2.username, "ranking order differs");
assert_eq!(
e1.score_raw.unwrap().to_bits(),
e2.score_raw.unwrap().to_bits(),
"scores differ for {}",
e1.username
);
}
}
#[test]
fn test_reviewer_split_is_exhaustive() {
// A reviewer on an MR must appear in exactly one of: participated or assigned-only.
// Three cases: (1) substantive notes -> participated, (2) no notes -> assigned,
// (3) trivial notes -> assigned.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
insert_mr(&conn, 1, 1, 100, "the_author", "merged");
insert_file_change(&conn, 1, 1, "src/lib.rs", "modified");
insert_discussion(&conn, 1, 1, Some(1), None, true, false);
// Case 1: substantive notes
insert_diffnote(
&conn,
1,
1,
1,
"reviewer_substantive",
"src/lib.rs",
"this is a long enough substantive review comment",
);
insert_reviewer(&conn, 1, "reviewer_substantive");
// Case 2: no notes at all
insert_reviewer(&conn, 1, "reviewer_no_notes");
// Case 3: trivial notes only
insert_diffnote(&conn, 2, 1, 1, "reviewer_trivial", "src/lib.rs", "ok");
insert_reviewer(&conn, 1, "reviewer_trivial");
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
now + 1000,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
// Check each reviewer's components
let subst = result
.experts
.iter()
.find(|e| e.username == "reviewer_substantive")
.unwrap();
let subst_comp = subst.components.as_ref().unwrap();
assert!(
subst_comp.reviewer_participated > 0.0,
"case 1: should be participated"
);
assert!(
subst_comp.reviewer_assigned < 0.01,
"case 1: should NOT be assigned"
);
let no_notes = result
.experts
.iter()
.find(|e| e.username == "reviewer_no_notes")
.unwrap();
let nn_comp = no_notes.components.as_ref().unwrap();
assert!(
nn_comp.reviewer_assigned > 0.0,
"case 2: should be assigned"
);
assert!(
nn_comp.reviewer_participated < 0.01,
"case 2: should NOT be participated"
);
let trivial = result
.experts
.iter()
.find(|e| e.username == "reviewer_trivial")
.unwrap();
let tr_comp = trivial.components.as_ref().unwrap();
assert!(
tr_comp.reviewer_assigned > 0.0,
"case 3: should be assigned"
);
assert!(
tr_comp.reviewer_participated < 0.01,
"case 3: should NOT be participated"
);
}
#[test]
fn test_deterministic_accumulation_order() {
// Same data queried 50 times must produce bit-identical f64 scores.
let conn = setup_test_db();
insert_project(&conn, 1, "team/backend");
let now = now_ms();
let day_ms: i64 = 86_400_000;
// 10 MRs at varied ages
for i in 1_i64..=10 {
let ts = now - i * 20 * day_ms;
insert_mr_at(
&conn,
i,
1,
i * 100,
"the_author",
"merged",
ts,
Some(ts),
None,
);
insert_file_change(&conn, i, 1, "src/lib.rs", "modified");
}
let as_of = now + 1000;
let first_result = query_expert(
&conn,
"src/lib.rs",
None,
0,
as_of,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
let expected_bits = first_result.experts[0].score_raw.unwrap().to_bits();
for run in 1..50 {
let result = query_expert(
&conn,
"src/lib.rs",
None,
0,
as_of,
20,
&default_scoring(),
false,
true,
false,
)
.unwrap();
assert_eq!(
result.experts[0].score_raw.unwrap().to_bits(),
expected_bits,
"run {run}: score bits diverged (HashMap iteration order leaking)"
);
}
}