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>
3268 lines
91 KiB
Rust
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)"
|
|
);
|
|
}
|
|
}
|