use std::path::PathBuf;use std::sync::Arc; use ai::agent::action::InsertReviewComment;use chrono::Local;use lsp::LspManagerModel;use repo_metadata::repositories::DetectedRepositories;use warp_core::ui::appearance::Appearance;use warp_editor::content::buffer::InitialBufferState;use warp_editor::render::element::VerticalExpansionBehavior;use warp_editor::render::model::LineCount;use warpui::elements::{Empty, MouseStateHandle};use warpui::platform::WindowStyle;use warpui::{App, ViewHandle}; use super::*;use crate::NotebookKeybindings;use crate::ai::persisted_workspace::PersistedWorkspace;use crate::ai::request_usage_model::AIRequestUsageModel;use crate::auth::AuthStateProvider;use crate::cloud_object::model::persistence::CloudModel;use crate::code::buffer_location::LocalOrRemotePath;use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};use crate::code::local_code_editor::LocalCodeEditorView;use crate::code_review::GlobalCodeReviewModel;use crate::code_review::comments::{ AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin, ImportedCommentDetails, LineDiffContent, PendingImportedReviewComment, PendingImportedReviewCommentTarget, attach_pending_imported_comments,};use crate::code_review::diff_size_limits::DiffSize;use crate::code_review::diff_state::{DiffStateModel, FileDiff, GitFileStatus};use crate::code_review::editor_state::CodeReviewEditorState;use crate::code_review::git_repo_model::GitRepoModels;use crate::pane_group::WorkingDirectoriesModel;use crate::server::server_api::ServerApiProvider;use crate::server::server_api::team::MockTeamClient;use crate::server::server_api::workspace::MockWorkspaceClient;use crate::server::telemetry::context_provider::AppTelemetryContextProvider;use crate::settings_view::keybindings::KeybindingChangedNotifier;use crate::terminal::local_shell::LocalShellState;use crate::test_util::settings::initialize_settings_for_tests;use crate::vim_registers::VimRegisters;use crate::workspace::ActiveSession;use crate::workspace::sync_inputs::SyncedInputState;use crate::workspaces::user_workspaces::UserWorkspaces; #[derive(Default)]struct TestView; impl warpui::Entity for TestView { type Event = ();} impl warpui::View for TestView { fn render(&self, _: &warpui::AppContext) -> Box<dyn warpui::Element> { Empty::new().finish() } fn ui_name() -> &'static str { "TestView" }} impl warpui::TypedActionView for TestView { type Action = ();} /// Initialize required singletons for testingfn initialize_test_app(app: &mut App) { initialize_settings_for_tests(app); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| SyncedInputState::mock()); app.add_singleton_model(|_| VimRegisters::new()); app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); app.add_singleton_model(|_| DetectedRepositories::default()); app.add_singleton_model(|_| GitRepoModels::new()); app.add_singleton_model(|_| LspManagerModel::new()); app.add_singleton_model(|_| LocalShellState::NotLoaded); app.add_singleton_model(PersistedWorkspace::new_for_test); app.add_singleton_model(|_| GlobalCodeReviewModel); app.add_singleton_model(|ctx| { UserWorkspaces::mock( Arc::new(MockTeamClient::new()), Arc::new(MockWorkspaceClient::new()), vec![], ctx, ) }); // Add mocks required by rich text editor (used in the CommentEditor) app.add_singleton_model(CloudModel::mock); app.add_singleton_model(|_| ActiveSession::default()); app.add_singleton_model(NotebookKeybindings::new); app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|ctx| { AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx) });} /// Creates a LocalCodeEditorView with the given contentfn create_editor_with_content(app: &mut App, content: &str) -> ViewHandle<LocalCodeEditorView> { let content = content.to_string(); let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { let code_editor_view = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight), ctx, ) }); code_editor_view.update(ctx, |editor, ctx| { editor.reset(InitialBufferState::plain_text(&content), ctx); }); LocalCodeEditorView::new(code_editor_view, None, false, None, ctx) }); local_editor} /// Creates a LocalCodeEditorView with base and current content for diff testing#[allow(dead_code)]fn create_editor_with_diff( app: &mut App, base_content: &str, current_content: &str,) -> ViewHandle<LocalCodeEditorView> { let current = current_content.to_string(); let base = base_content.to_string(); let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { let code_editor_view = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight), ctx, ) }); code_editor_view.update(ctx, |editor, ctx| { editor.reset(InitialBufferState::plain_text(¤t), ctx); editor.set_base(&base, true, ctx); }); LocalCodeEditorView::new(code_editor_view, None, false, None, ctx) }); local_editor} /// Creates an attached review comment with a Line targetfn create_line_comment( file_path: impl Into<PathBuf>, line_number: usize, line_text: &str, comment_content: &str,) -> AttachedReviewComment { let line_count = LineCount::from(line_number); AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::Line { absolute_file_path: LocalOrRemotePath::Local(file_path.into()), line: EditorLineLocation::Current { line_number: line_count, line_range: line_count..LineCount::from(line_number + 1), }, content: LineDiffContent { content: format!("+{line_text}"), lines_added: LineCount::from(1), lines_removed: LineCount::from(0), }, }, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::Native, }} /// Creates an imported (from GitHub) line comment whose stored `content` is the/// raw unified-diff line, including its one-char marker. `raw_diff_content`/// should therefore carry the leading `+`/`-`/space (e.g. `" line 2"` for a/// context line, `"+added"` for an addition).fn create_imported_line_comment( file_path: impl Into<PathBuf>, line_number: usize, raw_diff_content: &str, comment_content: &str,) -> AttachedReviewComment { let line_count = LineCount::from(line_number); AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::Line { absolute_file_path: LocalOrRemotePath::Local(file_path.into()), line: EditorLineLocation::Current { line_number: line_count, line_range: line_count..LineCount::from(line_number + 1), }, content: LineDiffContent { content: raw_diff_content.to_string(), lines_added: LineCount::from(0), lines_removed: LineCount::from(0), }, }, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::ImportedFromGitHub(ImportedCommentDetails { author: "reviewer".to_string(), github_comment_id: "1".to_string(), github_parent_id: None, html_url: None, }), }} /// Creates an attached review comment with a File targetfn create_file_comment( file_path: impl Into<PathBuf>, comment_content: &str,) -> AttachedReviewComment { AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::File { absolute_file_path: LocalOrRemotePath::Local(file_path.into()), }, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::Native, }} /// Creates an attached review comment with a General targetfn create_general_comment(comment_content: &str) -> AttachedReviewComment { AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::General, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::Native, }} fn make_pending_comment( id: &str, author: &str, body: &str, parent_id: Option<&str>, timestamp: &str, target: PendingImportedReviewCommentTarget,) -> PendingImportedReviewComment { let mut pending = PendingImportedReviewComment::try_from(InsertReviewComment { comment_id: id.to_string(), author: author.to_string(), comment_body: body.to_string(), parent_comment_id: parent_id.map(|s| s.to_string()), last_modified_timestamp: timestamp.to_string(), comment_location: None, html_url: None, }) .expect("valid pending import conversion"); // Override the location target since we intentionally use `comment_location: None` above. pending.target = target; pending} use crate::view_components::action_button::{ActionButton, NakedTheme}; /// Test context that holds all common test statestruct TestContext { repo_path: PathBuf, repo_location: LocalOrRemotePath, #[allow(dead_code)] window_id: warpui::WindowId, state: LoadedState, code_review_view: ViewHandle<CodeReviewView>,} impl TestContext { /// Initialize common test state with a single file editor fn new(app: &mut App, file_path: impl Into<String>, editor_content: &str) -> Self { initialize_test_app(app); let editor = create_editor_with_content(app, editor_content); let repo_path = PathBuf::from("/repo"); let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView); let state = create_loaded_state_with_editors(app, window_id, vec![(file_path.into(), editor)]); let diff_state_model = app.add_model(DiffStateModel::new_for_test); let working_directories_model = app.add_model(|_| WorkingDirectoriesModel::new()); let repo_key = LocalOrRemotePath::Local(repo_path.clone()); let code_review_comment_batch = working_directories_model.update(app, |working_directories, ctx| { working_directories.get_or_create_code_review_comments(&repo_key, ctx) }); let code_review_view = app.add_view(window_id, |ctx| { CodeReviewView::new( Some(repo_key.clone()), diff_state_model, code_review_comment_batch, None, ctx, ) }); Self { repo_path: repo_path.clone(), repo_location: LocalOrRemotePath::Local(repo_path), window_id, state, code_review_view, } }} /// Creates a minimal LoadedState with file states containing editors./// Must be called within an App context.fn create_loaded_state_with_editors( app: &mut App, window_id: warpui::WindowId, file_editors: Vec<(String, ViewHandle<LocalCodeEditorView>)>,) -> LoadedState { let file_states = file_editors .into_iter() .map(|(file_path, editor)| { let chevron_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let open_in_tab_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let discard_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let add_context_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let copy_path_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let state = FileState { file_diff: FileDiff { file_path: file_path.clone(), status: GitFileStatus::Modified, hunks: Arc::new(vec![]), is_binary: false, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Normal, }, editor_state: Some(CodeReviewEditorState::new_loaded(editor)), is_expanded: true, sidebar_mouse_state: MouseStateHandle::default(), header_mouse_state: MouseStateHandle::default(), chevron_button, open_in_tab_button, discard_button, add_context_button, copy_path_button, }; (file_path, state) }) .collect(); LoadedState { file_states, total_additions: 0, total_deletions: 0, files_changed: 0, }} #[test]fn test_relocate_comments_empty_input() { App::test((), |mut app| async move { let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3"); ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments(vec![], &ctx.state, &ctx.repo_location, view_ctx); assert!( relocated.is_empty(), "Empty input should return empty output" ); assert_eq!(fallbacks, 0, "Empty input should have no fallbacks"); }); });} #[test]fn test_relocate_comments_general_comment_passes_through() { App::test((), |mut app| async move { let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3"); let general_comment = create_general_comment("This is a general comment"); let original_id = general_comment.id; ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments( vec![general_comment], &ctx.state, &ctx.repo_location, view_ctx, ); assert_eq!(relocated.len(), 1, "Should return the comment"); assert_eq!(relocated[0].id, original_id, "Should preserve comment ID"); assert!( matches!(relocated[0].target, AttachedReviewCommentTarget::General), "General comment should remain General" ); assert_eq!( fallbacks, 0, "General comments should not count as fallbacks" ); }); });} #[test]fn test_relocate_comments_file_comment_passes_through() { App::test((), |mut app| async move { let file_path = "test.txt"; let ctx = TestContext::new(&mut app, file_path, "line 1\nline 2\nline 3"); let file_comment = create_file_comment(ctx.repo_path.join(file_path), "This is a file comment"); let original_id = file_comment.id; ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments( vec![file_comment], &ctx.state, &ctx.repo_location, view_ctx, ); assert_eq!(relocated.len(), 1, "Should return the comment"); assert_eq!(relocated[0].id, original_id, "Should preserve comment ID"); assert!( matches!( relocated[0].target, AttachedReviewCommentTarget::File { .. } ), "File comment should remain File" ); assert_eq!(fallbacks, 0, "File comments should not count as fallbacks"); }); });} #[test]fn test_relocate_comments_line_comment_no_matching_editor_marked_outdated() { App::test((), |mut app| async move { // Editor is for "test.txt" but comment is for "other.txt" let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3"); let line_comment = create_line_comment("/repo/other.txt", 1, "line 1", "Comment on other file"); let original_id = line_comment.id; ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments( vec![line_comment], &ctx.state, &ctx.repo_location, view_ctx, ); assert_eq!( relocated.len(), 1, "Comment with no matching editor should be kept but marked outdated" ); assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");Lines 1–500 of 1157. Load more
use std::path::PathBuf; use std::sync::Arc; use ai::agent::action::InsertReviewComment; use chrono::Local; use lsp::LspManagerModel; use repo_metadata::repositories::DetectedRepositories; use warp_core::ui::appearance::Appearance; use warp_editor::content::buffer::InitialBufferState; use warp_editor::render::element::VerticalExpansionBehavior; use warp_editor::render::model::LineCount; use warpui::elements::{Empty, MouseStateHandle}; use warpui::platform::WindowStyle; use warpui::{App, ViewHandle}; use super::*; use crate::NotebookKeybindings; use crate::ai::persisted_workspace::PersistedWorkspace; use crate::ai::request_usage_model::AIRequestUsageModel; use crate::auth::AuthStateProvider; use crate::cloud_object::model::persistence::CloudModel; use crate::code::buffer_location::LocalOrRemotePath; use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView}; use crate::code::local_code_editor::LocalCodeEditorView; use crate::code_review::GlobalCodeReviewModel; use crate::code_review::comments::{ AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin, ImportedCommentDetails, LineDiffContent, PendingImportedReviewComment, PendingImportedReviewCommentTarget, attach_pending_imported_comments, }; use crate::code_review::diff_size_limits::DiffSize; use crate::code_review::diff_state::{DiffStateModel, FileDiff, GitFileStatus}; use crate::code_review::editor_state::CodeReviewEditorState; use crate::code_review::git_repo_model::GitRepoModels; use crate::pane_group::WorkingDirectoriesModel; use crate::server::server_api::ServerApiProvider; use crate::server::server_api::team::MockTeamClient; use crate::server::server_api::workspace::MockWorkspaceClient; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::terminal::local_shell::LocalShellState; use crate::test_util::settings::initialize_settings_for_tests; use crate::vim_registers::VimRegisters; use crate::workspace::ActiveSession; use crate::workspace::sync_inputs::SyncedInputState; use crate::workspaces::user_workspaces::UserWorkspaces; #[derive(Default)] struct TestView; impl warpui::Entity for TestView { type Event = (); } impl warpui::View for TestView { fn render(&self, _: &warpui::AppContext) -> Box<dyn warpui::Element> { Empty::new().finish() } fn ui_name() -> &'static str { "TestView" } } impl warpui::TypedActionView for TestView { type Action = (); } /// Initialize required singletons for testing fn initialize_test_app(app: &mut App) { initialize_settings_for_tests(app); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| SyncedInputState::mock()); app.add_singleton_model(|_| VimRegisters::new()); app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); app.add_singleton_model(|_| DetectedRepositories::default()); app.add_singleton_model(|_| GitRepoModels::new()); app.add_singleton_model(|_| LspManagerModel::new()); app.add_singleton_model(|_| LocalShellState::NotLoaded); app.add_singleton_model(PersistedWorkspace::new_for_test); app.add_singleton_model(|_| GlobalCodeReviewModel); app.add_singleton_model(|ctx| { UserWorkspaces::mock( Arc::new(MockTeamClient::new()), Arc::new(MockWorkspaceClient::new()), vec![], ctx, ) }); // Add mocks required by rich text editor (used in the CommentEditor) app.add_singleton_model(CloudModel::mock); app.add_singleton_model(|_| ActiveSession::default()); app.add_singleton_model(NotebookKeybindings::new); app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|ctx| { AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx) }); } /// Creates a LocalCodeEditorView with the given content fn create_editor_with_content(app: &mut App, content: &str) -> ViewHandle<LocalCodeEditorView> { let content = content.to_string(); let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { let code_editor_view = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight), ctx, ) }); code_editor_view.update(ctx, |editor, ctx| { editor.reset(InitialBufferState::plain_text(&content), ctx); }); LocalCodeEditorView::new(code_editor_view, None, false, None, ctx) }); local_editor } /// Creates a LocalCodeEditorView with base and current content for diff testing #[allow(dead_code)] fn create_editor_with_diff( app: &mut App, base_content: &str, current_content: &str, ) -> ViewHandle<LocalCodeEditorView> { let current = current_content.to_string(); let base = base_content.to_string(); let (_, local_editor) = app.add_window(WindowStyle::NotStealFocus, move |ctx| { let code_editor_view = ctx.add_typed_action_view(|ctx| { CodeEditorView::new( None, None, CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight), ctx, ) }); code_editor_view.update(ctx, |editor, ctx| { editor.reset(InitialBufferState::plain_text(¤t), ctx); editor.set_base(&base, true, ctx); }); LocalCodeEditorView::new(code_editor_view, None, false, None, ctx) }); local_editor } /// Creates an attached review comment with a Line target fn create_line_comment( file_path: impl Into<PathBuf>, line_number: usize, line_text: &str, comment_content: &str, ) -> AttachedReviewComment { let line_count = LineCount::from(line_number); AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::Line { absolute_file_path: LocalOrRemotePath::Local(file_path.into()), line: EditorLineLocation::Current { line_number: line_count, line_range: line_count..LineCount::from(line_number + 1), }, content: LineDiffContent { content: format!("+{line_text}"), lines_added: LineCount::from(1), lines_removed: LineCount::from(0), }, }, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::Native, } } /// Creates an imported (from GitHub) line comment whose stored `content` is the /// raw unified-diff line, including its one-char marker. `raw_diff_content` /// should therefore carry the leading `+`/`-`/space (e.g. `" line 2"` for a /// context line, `"+added"` for an addition). fn create_imported_line_comment( file_path: impl Into<PathBuf>, line_number: usize, raw_diff_content: &str, comment_content: &str, ) -> AttachedReviewComment { let line_count = LineCount::from(line_number); AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::Line { absolute_file_path: LocalOrRemotePath::Local(file_path.into()), line: EditorLineLocation::Current { line_number: line_count, line_range: line_count..LineCount::from(line_number + 1), }, content: LineDiffContent { content: raw_diff_content.to_string(), lines_added: LineCount::from(0), lines_removed: LineCount::from(0), }, }, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::ImportedFromGitHub(ImportedCommentDetails { author: "reviewer".to_string(), github_comment_id: "1".to_string(), github_parent_id: None, html_url: None, }), } } /// Creates an attached review comment with a File target fn create_file_comment( file_path: impl Into<PathBuf>, comment_content: &str, ) -> AttachedReviewComment { AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::File { absolute_file_path: LocalOrRemotePath::Local(file_path.into()), }, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::Native, } } /// Creates an attached review comment with a General target fn create_general_comment(comment_content: &str) -> AttachedReviewComment { AttachedReviewComment { id: CommentId::new(), content: comment_content.to_string(), target: AttachedReviewCommentTarget::General, last_update_time: Local::now(), base: None, head: None, outdated: false, origin: CommentOrigin::Native, } } fn make_pending_comment( id: &str, author: &str, body: &str, parent_id: Option<&str>, timestamp: &str, target: PendingImportedReviewCommentTarget, ) -> PendingImportedReviewComment { let mut pending = PendingImportedReviewComment::try_from(InsertReviewComment { comment_id: id.to_string(), author: author.to_string(), comment_body: body.to_string(), parent_comment_id: parent_id.map(|s| s.to_string()), last_modified_timestamp: timestamp.to_string(), comment_location: None, html_url: None, }) .expect("valid pending import conversion"); // Override the location target since we intentionally use `comment_location: None` above. pending.target = target; pending } use crate::view_components::action_button::{ActionButton, NakedTheme}; /// Test context that holds all common test state struct TestContext { repo_path: PathBuf, repo_location: LocalOrRemotePath, #[allow(dead_code)] window_id: warpui::WindowId, state: LoadedState, code_review_view: ViewHandle<CodeReviewView>, } impl TestContext { /// Initialize common test state with a single file editor fn new(app: &mut App, file_path: impl Into<String>, editor_content: &str) -> Self { initialize_test_app(app); let editor = create_editor_with_content(app, editor_content); let repo_path = PathBuf::from("/repo"); let (window_id, _) = app.add_window(WindowStyle::NotStealFocus, |_| TestView); let state = create_loaded_state_with_editors(app, window_id, vec![(file_path.into(), editor)]); let diff_state_model = app.add_model(DiffStateModel::new_for_test); let working_directories_model = app.add_model(|_| WorkingDirectoriesModel::new()); let repo_key = LocalOrRemotePath::Local(repo_path.clone()); let code_review_comment_batch = working_directories_model.update(app, |working_directories, ctx| { working_directories.get_or_create_code_review_comments(&repo_key, ctx) }); let code_review_view = app.add_view(window_id, |ctx| { CodeReviewView::new( Some(repo_key.clone()), diff_state_model, code_review_comment_batch, None, ctx, ) }); Self { repo_path: repo_path.clone(), repo_location: LocalOrRemotePath::Local(repo_path), window_id, state, code_review_view, } } } /// Creates a minimal LoadedState with file states containing editors. /// Must be called within an App context. fn create_loaded_state_with_editors( app: &mut App, window_id: warpui::WindowId, file_editors: Vec<(String, ViewHandle<LocalCodeEditorView>)>, ) -> LoadedState { let file_states = file_editors .into_iter() .map(|(file_path, editor)| { let chevron_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let open_in_tab_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let discard_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let add_context_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let copy_path_button = app.add_view(window_id, |_| ActionButton::new("", NakedTheme)); let state = FileState { file_diff: FileDiff { file_path: file_path.clone(), status: GitFileStatus::Modified, hunks: Arc::new(vec![]), is_binary: false, is_autogenerated: false, max_line_number: 0, has_hidden_bidi_chars: false, size: DiffSize::Normal, }, editor_state: Some(CodeReviewEditorState::new_loaded(editor)), is_expanded: true, sidebar_mouse_state: MouseStateHandle::default(), header_mouse_state: MouseStateHandle::default(), chevron_button, open_in_tab_button, discard_button, add_context_button, copy_path_button, }; (file_path, state) }) .collect(); LoadedState { file_states, total_additions: 0, total_deletions: 0, files_changed: 0, } } #[test] fn test_relocate_comments_empty_input() { App::test((), |mut app| async move { let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3"); ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments(vec![], &ctx.state, &ctx.repo_location, view_ctx); assert!( relocated.is_empty(), "Empty input should return empty output" ); assert_eq!(fallbacks, 0, "Empty input should have no fallbacks"); }); }); } #[test] fn test_relocate_comments_general_comment_passes_through() { App::test((), |mut app| async move { let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3"); let general_comment = create_general_comment("This is a general comment"); let original_id = general_comment.id; ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments( vec![general_comment], &ctx.state, &ctx.repo_location, view_ctx, ); assert_eq!(relocated.len(), 1, "Should return the comment"); assert_eq!(relocated[0].id, original_id, "Should preserve comment ID"); assert!( matches!(relocated[0].target, AttachedReviewCommentTarget::General), "General comment should remain General" ); assert_eq!( fallbacks, 0, "General comments should not count as fallbacks" ); }); }); } #[test] fn test_relocate_comments_file_comment_passes_through() { App::test((), |mut app| async move { let file_path = "test.txt"; let ctx = TestContext::new(&mut app, file_path, "line 1\nline 2\nline 3"); let file_comment = create_file_comment(ctx.repo_path.join(file_path), "This is a file comment"); let original_id = file_comment.id; ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments( vec![file_comment], &ctx.state, &ctx.repo_location, view_ctx, ); assert_eq!(relocated.len(), 1, "Should return the comment"); assert_eq!(relocated[0].id, original_id, "Should preserve comment ID"); assert!( matches!( relocated[0].target, AttachedReviewCommentTarget::File { .. } ), "File comment should remain File" ); assert_eq!(fallbacks, 0, "File comments should not count as fallbacks"); }); }); } #[test] fn test_relocate_comments_line_comment_no_matching_editor_marked_outdated() { App::test((), |mut app| async move { // Editor is for "test.txt" but comment is for "other.txt" let ctx = TestContext::new(&mut app, "test.txt", "line 1\nline 2\nline 3"); let line_comment = create_line_comment("/repo/other.txt", 1, "line 1", "Comment on other file"); let original_id = line_comment.id; ctx.code_review_view.update(&mut app, |_view, view_ctx| { let RelocateCommentsResult { comments: relocated, fallback_count: fallbacks, } = CodeReviewView::relocate_comments( vec![line_comment], &ctx.state, &ctx.repo_location, view_ctx, ); assert_eq!( relocated.len(), 1, "Comment with no matching editor should be kept but marked outdated" ); assert_eq!(relocated[0].id, original_id, "Should preserve comment ID");