use std::sync::{Arc, Mutex}; use remote_server::manager::RemoteServerManagerEvent; use warp_util::remote_path::RemotePath; use super::InternalRemoteDiffState; use crate::auth::AuthStateProvider; use crate::code_review::diff_size_limits::DiffSize; use crate::code_review::diff_state::{ DiffHunk, DiffLine, DiffLineType, DiffMetadata, DiffMetadataAgainstBase, DiffMode, DiffState, DiffStateModelEvent, DiffStats, FileDiff, FileDiffAndContent, GitDiffData, GitDiffWithBaseContent, GitFileStatus, RemoteDiffStateModel, }; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::util::git::Commit; impl RemoteDiffStateModel { fn new_for_test( mode: DiffMode, state: InternalRemoteDiffState, metadata: Option, ) -> Self { Self { remote_path: RemotePath::new( remote_server::HostId::new("test-host".to_string()), warp_util::standardized_path::StandardizedPath::try_new("/test/repo") .expect("test repo path should be valid and absolute"), ), mode, state, metadata, tracked_diff_load_start_time: None, } } } fn empty_metadata(branch: &str) -> DiffMetadata { DiffMetadata { main_branch_name: "main".to_string(), current_branch_name: branch.to_string(), against_head: DiffMetadataAgainstBase { aggregate_stats: DiffStats::default(), files: vec![], }, against_base_branch: None, has_head_commit: true, unpushed_commits: vec![], upstream_ref: None, } } /// Snapshot inputs grouped for ergonomic test construction. Mirrors the /// argument list of `RemoteDiffStateModel::apply_snapshot`. struct SnapshotInputs { metadata: Option, state: DiffState, diffs: Option, } fn loaded_snapshot_with_files(files: Vec) -> SnapshotInputs { let files_changed = files.len(); SnapshotInputs { metadata: Some(empty_metadata("feature")), state: DiffState::Loaded, diffs: Some(GitDiffWithBaseContent { files, total_additions: 1, total_deletions: 0, files_changed, }), } } fn simple_file(path: &str) -> FileDiffAndContent { simple_file_with_content(path, None) } fn simple_file_with_content(path: &str, content_at_base: Option<&str>) -> FileDiffAndContent { FileDiffAndContent { file_diff: FileDiff { file_path: path.to_string(), status: GitFileStatus::Modified, hunks: Arc::new(vec![DiffHunk { old_start_line: 1, old_line_count: 1, new_start_line: 1, new_line_count: 2, lines: vec![DiffLine { line_type: DiffLineType::Add, old_line_number: None, new_line_number: Some(2), text: "+new line".to_string(), no_trailing_newline: false, }], unified_diff_start: 0, unified_diff_end: 1, }]), is_binary: false, is_autogenerated: false, max_line_number: 10, has_hidden_bidi_chars: false, size: DiffSize::Normal, }, content_at_head: content_at_base.map(str::to_string), } } fn test_metadata(branch: &str) -> DiffMetadata { DiffMetadata { main_branch_name: "main".to_string(), current_branch_name: branch.to_string(), against_head: DiffMetadataAgainstBase { aggregate_stats: DiffStats { files_changed: 1, total_additions: 5, total_deletions: 2, }, files: vec![], }, against_base_branch: None, has_head_commit: true, unpushed_commits: vec![Commit { hash: "abc123".to_string(), subject: "test commit".to_string(), files_changed: 1, additions: 5, deletions: 2, files: vec![], }], upstream_ref: Some("origin/feature".to_string()), } } fn initialize_test_app(app: &mut warpui::App) { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); } #[test] fn apply_snapshot_loaded_with_diffs() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let SnapshotInputs { metadata, state, diffs, } = loaded_snapshot_with_files(vec![simple_file("src/main.rs")]); handle.update(&mut app, |m, ctx| { m.apply_snapshot(metadata, state, diffs, ctx) }); handle.read(&app, |m, _| { assert!(matches!(m.get(), DiffState::Loaded)); assert!(m.metadata.is_some()); }); }); } #[test] fn get_committed_branch_files_response_emits_domain_files() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let emitted = Arc::new(Mutex::new(Vec::new())); { let emitted = emitted.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if let DiffStateModelEvent::BranchCommittedFilesReceived(files) = event { emitted .lock() .expect("emitted mutex should not be poisoned") .push(files.clone()); } }); }); } // Success: proto entries are converted to domain entries and emitted. let proto_files = vec![remote_server::proto::FileChangeEntry { path: "src/main.rs".to_string(), additions: 3, deletions: 1, }]; handle.update(&mut app, |m, ctx| { m.handle_get_committed_branch_files_response(&Ok(proto_files), ctx); }); // Error: an empty list is emitted so the dialog shows an empty box // rather than stale data. handle.update(&mut app, |m, ctx| { m.handle_get_committed_branch_files_response(&Err("boom".to_string()), ctx); }); let emitted = emitted .lock() .expect("emitted mutex should not be poisoned"); assert_eq!(emitted.len(), 2); assert_eq!(emitted[0].len(), 1); assert_eq!(emitted[0][0].path, "src/main.rs"); assert_eq!(emitted[0][0].additions, 3); assert_eq!(emitted[0][0].deletions, 1); assert!(emitted[1].is_empty()); }); } #[test] fn apply_snapshot_loaded_preserves_content_at_base_in_event() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let emitted_content = Arc::new(Mutex::new(Vec::new())); { let emitted_content = emitted_content.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if let DiffStateModelEvent::NewDiffsComputed { diffs: Some(diffs), .. } = event { emitted_content .lock() .expect("emitted content mutex should not be poisoned") .push( diffs .files .first() .and_then(|file| file.content_at_head.clone()), ); } }); }); } let SnapshotInputs { metadata, state, diffs, } = loaded_snapshot_with_files(vec![simple_file_with_content( "src/main.rs", Some("base content"), )]); handle.update(&mut app, |m, ctx| { m.apply_snapshot(metadata, state, diffs, ctx) }); assert_eq!( emitted_content .lock() .expect("emitted content mutex should not be poisoned") .as_slice(), &[Some("base content".to_string())] ); }); } #[test] fn apply_snapshot_loaded_without_diffs_becomes_error() { warpui::App::test((), |mut app| async move { initialize_test_app(&mut app); let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); handle.update(&mut app, |m, ctx| { m.apply_snapshot(None, DiffState::Loaded, None, ctx) }); assert!(matches!( handle.read(&app, |m, _| m.get()), DiffState::Error(_) )); }); } #[test] fn apply_snapshot_not_in_repository() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); handle.update(&mut app, |m, ctx| { m.apply_snapshot(None, DiffState::NotInRepository, None, ctx) }); assert!(matches!( handle.read(&app, |m, _| m.get()), DiffState::NotInRepository )); }); } #[test] fn apply_snapshot_error_stores_message() { warpui::App::test((), |mut app| async move { initialize_test_app(&mut app); let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); handle.update(&mut app, |m, ctx| { m.apply_snapshot(None, DiffState::Error("git failed".to_string()), None, ctx) }); assert!( matches!(handle.read(&app, |m, _| m.get()), DiffState::Error(ref msg) if msg == "git failed") ); }); } #[test] fn mark_disconnected_transitions_state_and_emits_connection_lost() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let connection_lost_count = Arc::new(Mutex::new(0)); { let connection_lost_count = connection_lost_count.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if matches!(event, DiffStateModelEvent::ConnectionLost) { *connection_lost_count .lock() .expect("connection lost count mutex should not be poisoned") += 1; } }); }); } handle.update(&mut app, |m, ctx| m.mark_disconnected(ctx)); handle.read(&app, |m, _| { assert!(matches!(m.get(), DiffState::Disconnected)); }); assert_eq!( *connection_lost_count .lock() .expect("connection lost count mutex should not be poisoned"), 1 ); // Idempotent: a second call should not re-emit ConnectionLost. handle.update(&mut app, |m, ctx| m.mark_disconnected(ctx)); assert_eq!( *connection_lost_count .lock() .expect("connection lost count mutex should not be poisoned"), 1 ); }); } #[test] fn apply_metadata_first_time_sets_branch() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let meta = empty_metadata("feature"); handle.update(&mut app, |m, ctx| m.apply_metadata_update(&meta, ctx)); assert_eq!( handle .read(&app, |m, _| m.get_current_branch_name()) .as_deref(), Some("feature") ); }); } #[test] fn apply_metadata_branch_change_updates_branch() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, Some(test_metadata("feature-a")), ) }); let meta = empty_metadata("feature-b"); handle.update(&mut app, |m, ctx| m.apply_metadata_update(&meta, ctx)); assert_eq!( handle .read(&app, |m, _| m.get_current_branch_name()) .as_deref(), Some("feature-b") ); }); } #[test] fn apply_file_delta_ignored_when_not_loaded() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let file_path = "src/main.rs".to_string(); let diff = Some(simple_file("src/main.rs")); handle.update(&mut app, |m, ctx| { m.apply_file_delta(file_path, diff, None, ctx) }); assert!(matches!( handle.read(&app, |m, _| m.get()), DiffState::Loading )); }); } #[test] fn apply_file_delta_adds_file() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loaded(GitDiffData { files: vec![], total_additions: 0, total_deletions: 0, files_changed: 0, }), None, ) }); let file_path = "src/new.rs".to_string(); let diff = Some(simple_file("src/new.rs")); handle.update(&mut app, |m, ctx| { m.apply_file_delta(file_path, diff, None, ctx) }); assert!(matches!( handle.read(&app, |m, _| m.get()), DiffState::Loaded )); }); } #[test] fn apply_snapshot_preserves_repo_relative_file_paths() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let SnapshotInputs { metadata, state, diffs, } = loaded_snapshot_with_files(vec![simple_file("src/main.rs")]); handle.update(&mut app, |m, ctx| { m.apply_snapshot(metadata, state, diffs, ctx) }); handle.read(&app, |m, _| { let InternalRemoteDiffState::Loaded(diffs) = &m.state else { panic!("state should be Loaded"); }; assert_eq!(diffs.files.len(), 1); assert_eq!(diffs.files[0].file_path, "src/main.rs"); }); }); } #[test] fn apply_snapshot_emits_event_with_repo_relative_paths() { // Subscribers to NewDiffsComputed should see repo-relative paths so they // can index into the loaded state by the same key. warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let emitted_paths = Arc::new(Mutex::new(Vec::new())); { let emitted_paths = emitted_paths.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if let DiffStateModelEvent::NewDiffsComputed { diffs: Some(diffs), .. } = event { emitted_paths .lock() .expect("emitted paths mutex should not be poisoned") .extend( diffs .files .iter() .map(|file| file.file_diff.file_path.clone()), ); } }); }); } let SnapshotInputs { metadata, state, diffs, } = loaded_snapshot_with_files(vec![simple_file("src/main.rs")]); handle.update(&mut app, |m, ctx| { m.apply_snapshot(metadata, state, diffs, ctx) }); assert_eq!( emitted_paths .lock() .expect("emitted paths mutex should not be poisoned") .as_slice(), &[String::from("src/main.rs")] ); }); } #[test] fn apply_file_delta_preserves_repo_relative_file_path() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loaded(GitDiffData { files: vec![], total_additions: 0, total_deletions: 0, files_changed: 0, }), None, ) }); let file_path = "src/new.rs".to_string(); let diff = Some(simple_file("src/new.rs")); handle.update(&mut app, |m, ctx| { m.apply_file_delta(file_path, diff, None, ctx) }); handle.read(&app, |m, _| { let InternalRemoteDiffState::Loaded(diffs) = &m.state else { panic!("state should be Loaded"); }; assert_eq!(diffs.files.len(), 1); assert_eq!(diffs.files[0].file_path, "src/new.rs"); }); }); } #[test] fn apply_file_delta_emits_event_with_repo_relative_path() { // The SingleFileUpdated event payload should also use the repo-relative // path so subscribers can match against the stored state by key. warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loaded(GitDiffData { files: vec![], total_additions: 0, total_deletions: 0, files_changed: 0, }), None, ) }); let emitted_paths = Arc::new(Mutex::new(Vec::new())); { let emitted_paths = emitted_paths.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if let DiffStateModelEvent::SingleFileUpdated { path, .. } = event { emitted_paths .lock() .expect("emitted paths mutex should not be poisoned") .push(path.clone()); } }); }); } let file_path = "src/new.rs".to_string(); let diff = Some(simple_file("src/new.rs")); handle.update(&mut app, |m, ctx| { m.apply_file_delta(file_path, diff, None, ctx) }); assert_eq!( emitted_paths .lock() .expect("emitted paths mutex should not be poisoned") .as_slice(), &[String::from("src/new.rs")] ); }); } #[test] fn apply_file_delta_preserves_content_at_base_in_event() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loaded(GitDiffData { files: vec![], total_additions: 0, total_deletions: 0, files_changed: 0, }), None, ) }); let emitted_content = Arc::new(Mutex::new(Vec::new())); { let emitted_content = emitted_content.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if let DiffStateModelEvent::SingleFileUpdated { diff: Some(diff), .. } = event { emitted_content .lock() .expect("emitted content mutex should not be poisoned") .push(diff.content_at_head.clone()); } }); }); } let file_path = "src/new.rs".to_string(); let diff = Some(simple_file_with_content( "src/new.rs", Some("old file content"), )); handle.update(&mut app, |m, ctx| { m.apply_file_delta(file_path, diff, None, ctx) }); assert_eq!( emitted_content .lock() .expect("emitted content mutex should not be poisoned") .as_slice(), &[Some("old file content".to_string())] ); }); } #[test] fn apply_file_delta_none_removes_file() { warpui::App::test((), |mut app| async move { let existing = GitDiffData { files: vec![FileDiff { file_path: "src/old.rs".to_string(), 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, }], total_additions: 0, total_deletions: 0, files_changed: 1, }; let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loaded(existing), None, ) }); let file_path = "src/old.rs".to_string(); handle.update(&mut app, |m, ctx| { m.apply_file_delta(file_path, None, None, ctx) }); handle.read(&app, |m, _| { let InternalRemoteDiffState::Loaded(diffs) = &m.state else { panic!("state should remain loaded"); }; assert!(diffs.files.is_empty()); }); }); } #[test] fn read_api_with_metadata() { let m = RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, Some(test_metadata("feature")), ); assert_eq!(m.get_main_branch_name().as_deref(), Some("main")); assert_eq!(m.get_current_branch_name().as_deref(), Some("feature")); assert!(!m.is_on_main_branch()); assert_eq!(m.unpushed_commits().len(), 1); assert_eq!(m.upstream_ref(), Some("origin/feature")); assert!(m.upstream_differs_from_main()); assert!(m.has_head()); assert_eq!( m.get_uncommitted_stats() .expect("uncommitted stats should be present") .total_additions, 5 ); } #[test] fn read_api_defaults_without_metadata() { let m = RemoteDiffStateModel::new_for_test(DiffMode::Head, InternalRemoteDiffState::Loading, None); assert_eq!(m.get_main_branch_name(), None); assert_eq!(m.get_current_branch_name(), None); assert!(!m.is_on_main_branch()); assert!(m.unpushed_commits().is_empty()); assert!(m.upstream_ref().is_none()); assert!(!m.upstream_differs_from_main()); assert!(!m.has_head()); assert!(m.get_uncommitted_stats().is_none()); } #[test] fn is_on_main_branch_true_when_matching() { let mut meta = test_metadata("main"); meta.current_branch_name = "main".into(); let m = RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, Some(meta), ); assert!(m.is_on_main_branch()); } #[test] fn get_returns_each_state_variant() { assert!(matches!( RemoteDiffStateModel::new_for_test(DiffMode::Head, InternalRemoteDiffState::Loading, None) .get(), DiffState::Loading )); assert!(matches!( RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::NotInRepository, None ) .get(), DiffState::NotInRepository )); assert!(matches!( RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Error("x".into()), None ) .get(), DiffState::Error(_) )); assert!(matches!( RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Disconnected, None ) .get(), DiffState::Disconnected )); } #[test] fn diff_mode_preserved() { let m = RemoteDiffStateModel::new_for_test( DiffMode::OtherBranch("develop".into()), InternalRemoteDiffState::Loading, None, ); assert_eq!(m.diff_mode(), DiffMode::OtherBranch("develop".into())); } #[test] fn empty_branch_names_become_none() { let mut meta = test_metadata(""); meta.main_branch_name = String::new(); meta.current_branch_name = String::new(); let m = RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, Some(meta), ); assert_eq!(m.get_main_branch_name(), None); assert_eq!(m.get_current_branch_name(), None); } #[test] fn host_disconnected_for_matching_host_transitions_to_disconnected() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let connection_lost_count = Arc::new(Mutex::new(0)); { let connection_lost_count = connection_lost_count.clone(); app.update(|ctx| { ctx.subscribe_to_model(&handle, move |_, event, _| { if matches!(event, DiffStateModelEvent::ConnectionLost) { *connection_lost_count .lock() .expect("connection lost count mutex should not be poisoned") += 1; } }); }); } let event = RemoteServerManagerEvent::HostDisconnected { host_id: remote_server::HostId::new("test-host".to_string()), }; handle.update(&mut app, |m, ctx| m.handle_manager_event(&event, ctx)); handle.read(&app, |m, _| { assert!(matches!(m.get(), DiffState::Disconnected)); }); assert_eq!( *connection_lost_count .lock() .expect("connection lost count mutex should not be poisoned"), 1 ); }); } #[test] fn host_disconnected_for_other_host_is_ignored() { warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let event = RemoteServerManagerEvent::HostDisconnected { host_id: remote_server::HostId::new("other-host".to_string()), }; handle.update(&mut app, |m, ctx| m.handle_manager_event(&event, ctx)); handle.read(&app, |m, _| { assert!(matches!(m.get(), DiffState::Loading)); }); }); } #[test] fn session_disconnected_is_ignored_by_session_agnostic_model() { // Per-session lifecycle events are no longer the model's concern; the // manager picks a connected client at RPC dispatch time and only // host-level connect/disconnect drive state transitions. warpui::App::test((), |mut app| async move { let handle = app.add_model(|_ctx| { RemoteDiffStateModel::new_for_test( DiffMode::Head, InternalRemoteDiffState::Loading, None, ) }); let event = RemoteServerManagerEvent::SessionDisconnected { session_id: warp_core::SessionId::default(), host_id: remote_server::HostId::new("test-host".to_string()), exit_status: None, was_reconnect_attempt: false, }; handle.update(&mut app, |m, ctx| m.handle_manager_event(&event, ctx)); handle.read(&app, |m, _| { assert!(matches!(m.get(), DiffState::Loading)); }); }); }