//! Phase 11 plumbing tests: WorkspaceEditBuilder, Config desugar, Wiki //! resolution, collect_diagnostics composition. The async closed-doc //! loader (`Backend::read_or_open`) is exercised via integration of the //! pure pieces — the full handler path is exercised once Phase 13 wires //! it into `workspace/rename`. use std::path::PathBuf; use nuwiki_core::ast::{ inline::InlineNode, BlockNode, DocumentNode, ErrorNode, ParagraphNode, Position as AstPosition, Span, TextNode, }; use nuwiki_lsp::collect_diagnostics; use nuwiki_lsp::config::{config_from_json, Config, DiagnosticConfig, LinkSeverity, WikiConfig}; use nuwiki_lsp::edits::{ op_create, op_delete, op_rename, text_edit_delete, text_edit_insert, text_edit_replace, WorkspaceEditBuilder, }; use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId}; use serde_json::json; use tower_lsp::lsp_types::{DocumentChanges, Position as LspPosition, Url}; // ===== Edits ===== fn span_one_line(byte_col: u32, len: u32) -> Span { Span::new( AstPosition { line: 0, column: byte_col, offset: byte_col as usize, }, AstPosition { line: 0, column: byte_col + len, offset: (byte_col + len) as usize, }, ) } #[test] fn text_edit_replace_passthrough_in_utf8() { let edit = text_edit_replace(span_one_line(0, 5), "world".into(), "hello there", true); assert_eq!(edit.range.start.character, 0); assert_eq!(edit.range.end.character, 5); assert_eq!(edit.new_text, "world"); } #[test] fn text_edit_replace_converts_to_utf16_for_multibyte() { // "héllo" — h(1B) é(2B) l(1B) l(1B) o(1B); UTF-16 chars: 1+1+1+1+1=5. // span covers bytes 0..3 (h + é); UTF-16 character count = 2. let edit = text_edit_replace(span_one_line(0, 3), "X".into(), "héllo", false); assert_eq!(edit.range.start.character, 0); assert_eq!(edit.range.end.character, 2); } #[test] fn text_edit_insert_produces_zero_width_range() { let edit = text_edit_insert( LspPosition { line: 0, character: 4, }, "X".into(), ); assert_eq!(edit.range.start, edit.range.end); assert_eq!(edit.new_text, "X"); } #[test] fn text_edit_delete_has_empty_new_text() { let edit = text_edit_delete(span_one_line(0, 3), "abcdef", true); assert_eq!(edit.new_text, ""); assert_eq!(edit.range.end.character, 3); } #[test] fn builder_with_only_edits_uses_changes_map() { let uri = Url::parse("file:///tmp/a.wiki").unwrap(); let mut b = WorkspaceEditBuilder::new(); b.edit( uri.clone(), text_edit_replace(span_one_line(0, 3), "new".into(), "old text", true), ); let we = b.build(); assert!(we.changes.is_some()); assert!(we.document_changes.is_none()); let changes = we.changes.unwrap(); assert_eq!(changes.len(), 1); assert_eq!(changes[&uri].len(), 1); } #[test] fn builder_with_file_op_promotes_to_document_changes() { let old = Url::parse("file:///tmp/A.wiki").unwrap(); let new = Url::parse("file:///tmp/B.wiki").unwrap(); let mut b = WorkspaceEditBuilder::new(); b.file_op(op_rename(old.clone(), new.clone())); b.edit( new.clone(), text_edit_replace(span_one_line(0, 1), "X".into(), "Y", true), ); let we = b.build(); assert!(we.changes.is_none()); let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else { panic!("expected Operations variant"); }; // Rename comes first (file ops always precede content edits in the // builder), then the text edit on the renamed file. assert_eq!(ops.len(), 2); } #[test] fn builder_is_empty_until_something_pushed() { let mut b = WorkspaceEditBuilder::new(); assert!(b.is_empty()); b.file_op(op_delete(Url::parse("file:///x").unwrap())); assert!(!b.is_empty()); } #[test] fn op_create_round_trips_through_builder() { let uri = Url::parse("file:///tmp/new.wiki").unwrap(); let mut b = WorkspaceEditBuilder::new(); b.file_op(op_create(uri)); let we = b.build(); assert!(we.document_changes.is_some()); } // ===== Config ===== #[test] fn config_default_is_empty() { let c = Config::default(); assert!(c.wikis.is_empty()); assert_eq!(c.diagnostic.link_severity, LinkSeverity::Warning); } #[test] fn v1_0_single_wiki_desugars_to_one_entry() { let cfg = config_from_json(json!({ "wiki_root": "/tmp/vimwiki", "file_extension": ".wiki", "syntax": "vimwiki", })); assert_eq!(cfg.wikis.len(), 1); assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/vimwiki")); assert_eq!(cfg.wikis[0].file_extension, ".wiki"); assert_eq!(cfg.wikis[0].syntax, "vimwiki"); } #[test] fn explicit_wikis_list_overrides_legacy_shape() { let cfg = config_from_json(json!({ "wiki_root": "/tmp/IGNORED", "wikis": [ { "root": "/tmp/personal", "name": "personal" }, { "root": "/tmp/work", "syntax": "vimwiki" }, ], })); assert_eq!(cfg.wikis.len(), 2); assert_eq!(cfg.wikis[0].name, "personal"); assert_eq!(cfg.wikis[1].root, PathBuf::from("/tmp/work")); } #[test] fn empty_json_yields_no_wikis() { let cfg = config_from_json(json!({})); assert!(cfg.wikis.is_empty()); } #[test] fn invalid_json_leaves_existing_config_unchanged() { let mut cfg = Config::default(); cfg.wikis .push(WikiConfig::from_root(PathBuf::from("/tmp/a"))); cfg.apply_change(&json!("not a config object")); // Existing wikis still present. assert_eq!(cfg.wikis.len(), 1); } // ===== Wiki aggregate ===== fn wiki_at(id: u32, root: &str) -> Wiki { Wiki::new(WikiId(id), WikiConfig::from_root(PathBuf::from(root))) } #[test] fn build_wikis_assigns_sequential_ids() { let configs = vec![ WikiConfig::from_root(PathBuf::from("/tmp/a")), WikiConfig::from_root(PathBuf::from("/tmp/b")), ]; let wikis = build_wikis(&configs); assert_eq!(wikis.len(), 2); assert_eq!(wikis[0].id, WikiId(0)); assert_eq!(wikis[1].id, WikiId(1)); } #[test] fn resolve_uri_to_wiki_picks_longest_prefix() { let wikis = vec![wiki_at(0, "/wiki"), wiki_at(1, "/wiki/sub")]; let uri = Url::from_file_path("/wiki/sub/page.wiki").unwrap(); assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); } #[test] fn resolve_uri_to_wiki_misses_outside_any_root() { let wikis = vec![wiki_at(0, "/wiki")]; let uri = Url::from_file_path("/other/page.wiki").unwrap(); assert!(resolve_uri_to_wiki(&wikis, &uri).is_none()); } #[test] fn wiki_contains_respects_root_prefix() { let w = wiki_at(0, "/wiki"); assert!(w.contains(&Url::from_file_path("/wiki/note.wiki").unwrap())); assert!(!w.contains(&Url::from_file_path("/other/note.wiki").unwrap())); } // ===== Diagnostics composition ===== #[test] fn collect_diagnostics_emits_one_per_error_node() { let doc = DocumentNode { span: Span::default(), metadata: Default::default(), children: vec![ BlockNode::Error(ErrorNode { span: span_one_line(0, 3), raw: "oops".into(), message: "broken".into(), }), BlockNode::Paragraph(ParagraphNode { span: Span::default(), children: vec![InlineNode::Text(TextNode { span: Span::default(), content: "ok".into(), })], }), ], }; let cfg = DiagnosticConfig::default(); let diags = collect_diagnostics(&doc, "abc ok", None, None, None, &cfg, true); assert_eq!(diags.len(), 1); assert_eq!(diags[0].message, "broken"); } #[test] fn collect_diagnostics_link_health_emits_nothing_without_index() { // No index → link-health source is skipped, even when severity is on. let doc = DocumentNode::default(); let cfg = DiagnosticConfig { link_severity: LinkSeverity::Error, }; let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true); assert!(diags.is_empty()); }