use std::sync::mpsc::sync_channel; use ai::document::AIDocumentId; use galaxyui::App; use super::*; use crate::notebooks::CloudNotebookModel; use crate::persistence::ModelEvent; use crate::test_util::settings::initialize_settings_for_tests; fn completed_plan(completed_at: DateTime) -> CompletedPlan { CompletedPlan { conversation_id: AIConversationId::new(), completed_at, archived_to: None, original_folder: None, } } #[test] fn completed_plan_archives_after_seven_full_days_only() { let now = Utc::now(); let plan = completed_plan(now - chrono::Duration::days(7)); assert!(!plan.is_due(now - chrono::Duration::milliseconds(1))); assert!(plan.is_due(now)); assert!(!completed_plan(now + chrono::Duration::days(1)).is_due(now)); } #[test] fn archived_plan_deadline_and_recovery_location_survive_restart() { let mut plan = completed_plan(Utc::now() - chrono::Duration::days(8)); plan.archived_to = Some(SyncId::ClientId(ClientId::new())); plan.original_folder = Some(SyncId::ClientId(ClientId::new())); let serialized = serde_json::to_string(&plan).unwrap(); let restored: CompletedPlan = serde_json::from_str(&serialized).unwrap(); assert_eq!(restored, plan); assert!(!restored.is_due(Utc::now())); } #[test] fn revisiting_a_completed_plan_preserves_its_original_deadline() { let mut model = PlanArchivalModel { completed_plans: HashMap::new(), ..Default::default() }; let conversation_id = AIConversationId::new(); let completed_at = Utc::now() - chrono::Duration::days(6); model.observe_completion("plan".to_string(), conversation_id, completed_at); model.observe_completion("plan".to_string(), conversation_id, Utc::now()); assert_eq!(model.completed_plans["plan"].completed_at, completed_at); } #[test] fn legacy_completed_plan_records_are_preserved() { let record = completed_plan(Utc::now() - chrono::Duration::days(4)); let records = HashMap::from([("plan".to_string(), record.clone())]); let json = serde_json::to_string(&records).unwrap(); let model = PlanArchivalModel::from_persisted(&json).unwrap(); assert_eq!(model.completed_plans["plan"], record); assert!(model.reopened_plans.is_empty()); } #[test] fn dirty_and_reverted_plans_stay_visible_until_new_work_accepts_the_edits() { App::test((), |mut app| async move { initialize_settings_for_tests(&mut app); app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None)); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); let documents = app.add_singleton_model(|_| AIDocumentModel::new_for_test()); let repository = app.add_singleton_model(|ctx| { LocalObjectRepository::new(None, None, ctx) }); let document_id = AIDocumentId::new(); let notebook_id = SyncId::ClientId(ClientId::new()); repository.update(&mut app, |repository, ctx| { repository.create_notebook_with_id( notebook_id, None, CloudNotebookModel { title: "Reopened plan".to_string(), data: "New unfinished work".to_string(), ai_document_id: Some(document_id), conversation_id: None, }, ctx, ); }); let archival = app.add_model(PlanArchivalModel::new); let conversation_id = AIConversationId::new(); let old_completion = Utc::now() - chrono::Duration::days(8); archival.update(&mut app, |model, ctx| { model.observe_completion(document_id.to_string(), conversation_id, old_completion); model.archive_plan(&document_id.to_string(), ctx); }); assert!(CloudModel::as_ref(&app) .get_notebook(¬ebook_id) .unwrap() .metadata .folder_id .is_some()); // Typing in the editor emits Dirty; reverting a version also emits a // user-originated DocumentUpdated event. Either must reopen the plan. for event in [ AIDocumentModelEvent::DocumentUserEditStatusUpdated { document_id, status: AIDocumentUserEditStatus::Dirty, }, AIDocumentModelEvent::DocumentUpdated { document_id, version: Default::default(), source: AIDocumentUpdateSource::User, }, ] { documents.update(&mut app, |_, ctx| ctx.emit(event)); archival.update(&mut app, |model, ctx| { model.observe_completion(document_id.to_string(), conversation_id, old_completion); model.sweep(ctx); assert!(!model.completed_plans.contains_key(&document_id.to_string())); assert_eq!( model.reopened_plans[&document_id.to_string()].conversation_id, conversation_id, ); let json = serde_json::to_string(model).unwrap(); let mut restored = PlanArchivalModel::from_persisted(&json).unwrap(); restored.observe_completion( document_id.to_string(), conversation_id, old_completion, ); assert!(restored.completed_plans.is_empty()); }); assert!(CloudModel::as_ref(&app) .get_notebook(¬ebook_id) .unwrap() .metadata .folder_id .is_none()); } archival.update(&mut app, |model, ctx| { // Merely restoring clean editor state cannot close a reopened plan. model.accept_document_edits(document_id, ctx); assert!(model.reopened_plans.contains_key(&document_id.to_string())); model.begin_exchange(conversation_id, ctx); assert!(model.reopened_plans.contains_key(&document_id.to_string())); model.accept_document_edits(document_id, ctx); assert!(model.reopened_plans.is_empty()); let finished_again = Utc::now(); model.observe_completion(document_id.to_string(), conversation_id, finished_again); assert_eq!(model.completed_plans[&document_id.to_string()].completed_at, finished_again); assert!(!model.completed_plans[&document_id.to_string()].is_due(finished_again)); }); }); } #[test] fn a_successful_draft_conversation_does_not_complete_a_plan() { let mut conversation = AIConversation::new(false, false); conversation.set_status_for_test(ConversationStatus::Success); assert!(completed_work_time(&conversation).is_none()); for status in [ ConversationStatus::InProgress, ConversationStatus::WaitingForEvents, ConversationStatus::TransientError, ConversationStatus::Error, ConversationStatus::Cancelled, ] { conversation.set_status_for_test(status); assert!(completed_work_time(&conversation).is_none()); } } #[test] fn archival_preserves_notebook_and_resuming_restores_original_folder() { App::test((), |mut app| async move { initialize_settings_for_tests(&mut app); let (sender, receiver) = sync_channel(32); app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None)); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); app.add_singleton_model(|_| AIDocumentModel::new_for_test()); let repository = app.add_singleton_model(|ctx| { LocalObjectRepository::new(Some(sender), None, ctx) }); let archival = app.add_model(|_| PlanArchivalModel { completed_plans: HashMap::new(), ..Default::default() }); let original_folder = SyncId::ClientId(ClientId::new()); let notebook_id = SyncId::ClientId(ClientId::new()); let document_id = AIDocumentId::new(); let notebook_model = CloudNotebookModel { title: "Retained plan".to_string(), data: "# Plan\n- [x] Finished work".to_string(), ai_document_id: Some(document_id), conversation_id: None, }; repository.update(&mut app, |repository, ctx| { repository.create_folder_with_id( original_folder, "Project".to_string(), None, ctx, ); repository.create_notebook_with_id( notebook_id, Some(original_folder), notebook_model.clone(), ctx, ); }); let conversation_id = AIConversationId::new(); archival.update(&mut app, |model, ctx| { model.observe_completion( document_id.to_string(), conversation_id, Utc::now() - chrono::Duration::days(8), ); model.persist(ctx); }); // Simulate startup with persisted completion records and no loaded // conversations/documents. The notebook is still retained in Drive. let archival = app.add_model(PlanArchivalModel::new); archival.update(&mut app, |model, ctx| model.sweep(ctx)); let archive_folder = archival.read(&app, |model, _| { model.completed_plans[&document_id.to_string()] .archived_to .expect("plan should have been archived") }); let cloud_model = CloudModel::as_ref(&app); let notebook = cloud_model.get_notebook(¬ebook_id).unwrap(); assert_eq!(notebook.model(), ¬ebook_model); assert_eq!(notebook.metadata.folder_id, Some(archive_folder)); assert!(!notebook.is_trashed(cloud_model)); let folder = cloud_model.get_folder(&archive_folder).unwrap(); assert_eq!(folder.model().name, ARCHIVE_FOLDER_NAME); let parent = cloud_model .get_folder(&folder.metadata.folder_id.unwrap()) .unwrap(); assert_eq!(parent.model().name, PLAN_FOLDER_NAME); let events = receiver.try_iter().collect::>(); assert!(events.iter().any(|event| matches!( event, ModelEvent::UpsertNotebook { notebook } if notebook.id == notebook_id && notebook.metadata.folder_id == Some(archive_folder) && notebook.model() == ¬ebook_model ))); assert!(!events.iter().any(|event| matches!(event, ModelEvent::DeleteObjects { .. }))); archival.update(&mut app, |model, ctx| { model.resume_conversation(conversation_id, ctx); assert!(model.completed_plans.is_empty()); }); let cloud_model = CloudModel::as_ref(&app); let notebook = cloud_model.get_notebook(¬ebook_id).unwrap(); assert_eq!(notebook.metadata.folder_id, Some(original_folder)); assert_eq!(notebook.model(), ¬ebook_model); assert!(!notebook.is_trashed(cloud_model)); }); } #[test] fn manual_restore_does_not_rearchive_a_completed_plan() { App::test((), |mut app| async move { initialize_settings_for_tests(&mut app); app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None)); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); app.add_singleton_model(|_| AIDocumentModel::new_for_test()); let repository = app.add_singleton_model(|ctx| { LocalObjectRepository::new(None, None, ctx) }); let archive_folder = SyncId::ClientId(ClientId::new()); let notebook_id = SyncId::ClientId(ClientId::new()); let document_id = AIDocumentId::new(); repository.update(&mut app, |repository, ctx| { repository.create_folder_with_id(archive_folder, "Archive".to_string(), None, ctx); repository.create_notebook_with_id( notebook_id, Some(archive_folder), CloudNotebookModel { title: "Recovered plan".to_string(), data: "Keep this plan visible".to_string(), ai_document_id: Some(document_id), conversation_id: None, }, ctx, ); }); let mut record = completed_plan(Utc::now() - chrono::Duration::days(8)); record.archived_to = Some(archive_folder); let archival = app.add_model(|_| PlanArchivalModel { completed_plans: HashMap::from([(document_id.to_string(), record)]), ..Default::default() }); assert!(repository.update(&mut app, |repository, ctx| { repository.move_object_to_location( CloudObjectTypeAndId::Notebook(notebook_id), CloudObjectLocation::Space(Space::Personal), ctx, ) })); archival.update(&mut app, |model, ctx| model.sweep(ctx)); let cloud_model = CloudModel::as_ref(&app); let notebook = cloud_model.get_notebook(¬ebook_id).unwrap(); assert!(notebook.metadata.folder_id.is_none()); assert_eq!(notebook.model().data, "Keep this plan visible"); archival.read(&app, |model, _| { assert!(!model.completed_plans[&document_id.to_string()].is_due(Utc::now())); }); }); } #[test] fn ordinary_notebooks_are_never_archived() { App::test((), |mut app| async move { initialize_settings_for_tests(&mut app); app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None)); app.add_singleton_model(|_| AIDocumentModel::new_for_test()); let repository = app.add_singleton_model(|ctx| { LocalObjectRepository::new(None, None, ctx) }); let notebook_id = SyncId::ClientId(ClientId::new()); repository.update(&mut app, |repository, ctx| { repository.create_notebook_with_id( notebook_id, None, CloudNotebookModel { title: "Ordinary notes".to_string(), data: "Keep these notes".to_string(), ai_document_id: None, conversation_id: None, }, ctx, ); }); let archival = app.add_model(|_| PlanArchivalModel { completed_plans: HashMap::new(), ..Default::default() }); archival.update(&mut app, |model, ctx| { model.observe_completion( notebook_id.to_string(), AIConversationId::new(), Utc::now() - chrono::Duration::days(8), ); model.archive_plan(¬ebook_id.to_string(), ctx); }); let cloud_model = CloudModel::as_ref(&app); let notebook = cloud_model.get_notebook(¬ebook_id).unwrap(); assert!(notebook.metadata.folder_id.is_none()); assert_eq!(notebook.model().data, "Keep these notes"); assert_eq!(cloud_model.get_all_active_folders().count(), 0); }); } #[test] fn editing_an_archived_plan_from_drive_reopens_it_without_loading_its_conversation() { App::test((), |mut app| async move { initialize_settings_for_tests(&mut app); app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None)); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); app.add_singleton_model(|_| AIDocumentModel::new_for_test()); let repository = app.add_singleton_model(|ctx| { LocalObjectRepository::new(None, None, ctx) }); let document_id = AIDocumentId::new(); let notebook_id = SyncId::ClientId(ClientId::new()); repository.update(&mut app, |repository, ctx| { repository.create_notebook_with_id( notebook_id, None, CloudNotebookModel { title: "Completed plan".to_string(), data: "- [x] Done".to_string(), ai_document_id: Some(document_id), conversation_id: None, }, ctx, ); }); let archival = app.add_model(PlanArchivalModel::new); let conversation_id = AIConversationId::new(); let completed_at = Utc::now() - chrono::Duration::days(8); archival.update(&mut app, |model, ctx| { model.observe_completion(document_id.to_string(), conversation_id, completed_at); model.archive_plan(&document_id.to_string(), ctx); }); repository.update(&mut app, |repository, ctx| { repository.update_notebook_data( notebook_id, "- [ ] New unfinished work".to_string(), ctx, ); }); archival.update(&mut app, |model, ctx| { model.observe_completion(document_id.to_string(), conversation_id, completed_at); model.sweep(ctx); assert!(model.completed_plans.is_empty()); assert_eq!( model.reopened_plans[&document_id.to_string()].conversation_id, conversation_id, ); }); let cloud_model = CloudModel::as_ref(&app); let notebook = cloud_model.get_notebook(¬ebook_id).unwrap(); assert!(notebook.metadata.folder_id.is_none()); assert_eq!(notebook.model().data, "- [ ] New unfinished work"); assert!(!notebook.is_trashed(cloud_model)); }); }