use lsp::LspManagerModel; use remote_server::proto::TextEdit; use repo_metadata::RepoMetadataModel; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::watcher::DirectoryWatcher; use warp_files::FileModel; use warp_util::content_version::ContentVersion; use warp_util::host_id::HostId; use warp_util::standardized_path::StandardizedPath; use warpui::{App, ModelHandle, SingletonEntity}; use super::{BufferSource, CharOffsetEdit, GlobalBufferModel, PendingEditBatch}; use crate::test_util::settings::initialize_settings_for_tests; // ── Test-only helpers on GlobalBufferModel ──────────────────────── // These live here (child module) rather than in global_buffer_model.rs // to keep test infrastructure out of the production source file. // // Note: `seed_remote_buffer_for_test` and `sync_clock_for_remote_test` // are `pub(crate)` in global_buffer_model.rs because they're shared // with `buffer_location_tests`. impl GlobalBufferModel { /// Returns whether a pending edit batch exists for a Remote buffer. fn has_pending_batch_for_test(&self, file_id: warp_util::file::FileId) -> bool { self.buffers.get(&file_id).is_some_and(|state| { matches!(&state.source, BufferSource::Remote { pending_batch, .. } if pending_batch.is_some()) }) } /// Returns the number of edits in the pending batch, or 0 if none. fn pending_batch_edit_count_for_test(&self, file_id: warp_util::file::FileId) -> usize { self.buffers .get(&file_id) .and_then(|state| match &state.source { BufferSource::Remote { pending_batch, .. } => { pending_batch.as_ref().map(|b| b.edits.len()) } _ => None, }) .unwrap_or(0) } /// Inserts a fake pending batch so tests can verify discard/flush /// behavior without needing a real `RemoteServerClient` or the /// `ContentChanged` subscription path. fn insert_pending_batch_for_test( &mut self, file_id: warp_util::file::FileId, expected_server_version: u64, edits: Vec, client_version: ContentVersion, ) { let Some(state) = self.buffers.get_mut(&file_id) else { return; }; if let BufferSource::Remote { pending_batch, sync_clock, .. } = &mut state.source { if let Some(clock) = sync_clock.as_mut() { clock.client_version = client_version; } *pending_batch = Some(PendingEditBatch { expected_server_version, edits, latest_client_version: client_version, debounce_timer: None, }); } } } // ── Test setup ──────────────────────────────────────────────────── fn init_app(app: &mut App) { initialize_settings_for_tests(app); app.add_singleton_model(|_| LspManagerModel::new()); app.add_singleton_model(DirectoryWatcher::new); app.add_singleton_model(|_| DetectedRepositories::default()); app.add_singleton_model(RepoMetadataModel::new); app.add_singleton_model(FileModel::new); } fn gbm(app: &App) -> ModelHandle { GlobalBufferModel::handle(app) } fn content(app: &App, file_id: warp_util::file::FileId) -> String { let handle = gbm(app); app.read(|ctx| { handle .as_ref(ctx) .content_for_file(file_id, ctx) .unwrap_or_default() }) } fn text_edit(start: u64, end: u64, text: &str) -> TextEdit { TextEdit { start_offset: start, end_offset: end, text: text.to_string(), } } fn char_edit(start: usize, end: usize, text: &str) -> CharOffsetEdit { CharOffsetEdit { start: string_offset::CharOffset::from(start), end: string_offset::CharOffset::from(end), text: text.to_string(), } } fn test_host_id() -> HostId { HostId::new("test-host".to_string()) } fn test_path() -> StandardizedPath { StandardizedPath::try_new("/test/file.txt").unwrap() } // ── Pending edit batch: discard on server push ─────────────────── #[test] fn pending_batch_discarded_on_server_push_with_conflict() { App::test((), |mut app| async move { init_app(&mut app); app.add_singleton_model(GlobalBufferModel::new); let host_id = test_host_id(); let path = test_path(); // Seed a remote buffer at server_version=1, client_version=0. let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| { gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx) }); let file_id = _buffer_state.file_id; // Simulate client edits that haven't been flushed yet. let client_cv = ContentVersion::new(); gbm(&app).update(&mut app, |gbm, _ctx| { gbm.insert_pending_batch_for_test( file_id, 1, // expected_server_version vec![text_edit(6, 6, " world")], client_cv, ); }); // Verify the batch exists. let handle = gbm(&app); app.read(|ctx| { assert!(handle.as_ref(ctx).has_pending_batch_for_test(file_id)); assert_eq!( handle .as_ref(ctx) .pending_batch_edit_count_for_test(file_id), 1 ); }); // Server push arrives. Since client_cv != 0, this triggers a conflict // and the batch should be discarded. gbm(&app).update(&mut app, |gbm, ctx| { gbm.handle_buffer_updated_push( &host_id, path.as_str(), 2, // new_server_version 0, // expected_client_version (server doesn't know about our edits) &[char_edit(6, 6, " push")], ctx, ); }); // Batch should be discarded. let handle = gbm(&app); app.read(|ctx| { assert!( !handle.as_ref(ctx).has_pending_batch_for_test(file_id), "Pending batch should be discarded on server push" ); }); // Content should be unchanged (conflict path, push not applied). assert_eq!(content(&app, file_id), "hello"); }) } #[test] fn pending_batch_discarded_on_conflict_detected() { App::test((), |mut app| async move { init_app(&mut app); app.add_singleton_model(GlobalBufferModel::new); let host_id = test_host_id(); let path = test_path(); let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| { gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx) }); let file_id = _buffer_state.file_id; // Insert a pending batch. let client_cv = ContentVersion::new(); gbm(&app).update(&mut app, |gbm, _ctx| { gbm.insert_pending_batch_for_test( file_id, 1, vec![text_edit(6, 6, " edit")], client_cv, ); }); let handle = gbm(&app); app.read(|ctx| { assert!(handle.as_ref(ctx).has_pending_batch_for_test(file_id)); }); // BufferConflictDetected arrives. gbm(&app).update(&mut app, |gbm, ctx| { gbm.handle_buffer_conflict_detected(&host_id, path.as_str(), ctx); }); // Batch should be discarded. let handle = gbm(&app); app.read(|ctx| { assert!( !handle.as_ref(ctx).has_pending_batch_for_test(file_id), "Pending batch should be discarded on conflict detected" ); }); }) } #[test] fn server_push_accepted_without_pending_batch() { App::test((), |mut app| async move { init_app(&mut app); app.add_singleton_model(GlobalBufferModel::new); let host_id = test_host_id(); let path = test_path(); let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| { gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx) }); let file_id = _buffer_state.file_id; // No pending batch — clean push should be accepted. let handle = gbm(&app); app.read(|ctx| { assert!(!handle.as_ref(ctx).has_pending_batch_for_test(file_id)); }); gbm(&app).update(&mut app, |gbm, ctx| { gbm.handle_buffer_updated_push( &host_id, path.as_str(), 2, 0, // matches client_version=0 &[char_edit(6, 6, " world")], ctx, ); }); assert_eq!(content(&app, file_id), "hello world"); // Clock should be updated. let handle = gbm(&app); app.read(|ctx| { let clock = handle .as_ref(ctx) .sync_clock_for_remote_test(file_id) .unwrap(); assert_eq!(clock.server_version, ContentVersion::from_raw(2)); assert_eq!(clock.client_version, ContentVersion::from_raw(0)); }); }) } #[test] fn pending_batch_bumps_client_version_immediately() { App::test((), |mut app| async move { init_app(&mut app); app.add_singleton_model(GlobalBufferModel::new); let host_id = test_host_id(); let path = test_path(); let _buffer_state = gbm(&app).update(&mut app, |gbm, ctx| { gbm.seed_remote_buffer_for_test(host_id.clone(), path.clone(), "hello", 1, ctx) }); let file_id = _buffer_state.file_id; // Insert a batch — this simulates what the ContentChanged handler does: // sync_clock.client_version is bumped immediately. let client_cv = ContentVersion::new(); gbm(&app).update(&mut app, |gbm, _ctx| { gbm.insert_pending_batch_for_test( file_id, 1, vec![text_edit(6, 6, " edit")], client_cv, ); }); // The sync clock's client_version should already reflect the edit, // even though the batch hasn't been flushed. let handle = gbm(&app); app.read(|ctx| { let clock = handle .as_ref(ctx) .sync_clock_for_remote_test(file_id) .unwrap(); assert_eq!(clock.client_version, client_cv); // server_version unchanged assert_eq!(clock.server_version, ContentVersion::from_raw(1)); }); }) }