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:: } ], ) .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, issue_id: Option, 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, closed_at_ms: Option, ) { 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::::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::, _>>() .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::::None, now + 1000, 0.5_f64, 20_i64, ], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), ) .unwrap() .collect::, _>>() .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::::None, now + 1000, 0.5_f64, 20_i64, ], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, f64>(5)?, )) }, ) .unwrap() .collect::, _>>() .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 = 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)" ); } }