From db06dbf9763d6c610d1f7a267597ecdad309048a Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 18 Dec 2024 20:26:39 +0100 Subject: [PATCH] Further improve IDE tests (#5602) --- crates/typst-ide/src/complete.rs | 84 +++++++++++--------------- crates/typst-ide/src/definition.rs | 35 +++++------ crates/typst-ide/src/jump.rs | 22 ++++--- crates/typst-ide/src/matchers.rs | 90 ++++++++++++++++++---------- crates/typst-ide/src/tests.rs | 95 ++++++++++++++++++++++-------- crates/typst-ide/src/tooltip.rs | 29 ++++----- 6 files changed, 207 insertions(+), 148 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index c731165d7..c22ea7e40 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -1506,14 +1506,13 @@ impl BracketMode { #[cfg(test)] mod tests { + use std::borrow::Borrow; use std::collections::BTreeSet; use typst::layout::PagedDocument; - use typst::syntax::{FileId, Source, VirtualPath}; - use typst::World; use super::{autocomplete, Completion}; - use crate::tests::{SourceExt, TestWorld}; + use crate::tests::{FilePos, TestWorld, WorldLike}; /// Quote a string. macro_rules! q { @@ -1585,60 +1584,50 @@ mod tests { } #[track_caller] - fn test(text: &str, cursor: isize) -> Response { - let world = TestWorld::new(text); - test_with_world(&world, cursor) + fn test(world: impl WorldLike, pos: impl FilePos) -> Response { + let world = world.acquire(); + let world = world.borrow(); + let doc = typst::compile(world).output.ok(); + test_with_doc(world, pos, doc.as_ref()) } #[track_caller] - fn test_with_world(world: &TestWorld, cursor: isize) -> Response { - let doc = typst::compile(&world).output.ok(); - test_full(world, &world.main, doc.as_ref(), cursor) - } - - #[track_caller] - fn test_with_path(world: &TestWorld, path: &str, cursor: isize) -> Response { - let doc = typst::compile(&world).output.ok(); - let id = FileId::new(None, VirtualPath::new(path)); - let source = world.source(id).unwrap(); - test_full(world, &source, doc.as_ref(), cursor) - } - - #[track_caller] - fn test_full( - world: &TestWorld, - source: &Source, + fn test_with_doc( + world: impl WorldLike, + pos: impl FilePos, doc: Option<&PagedDocument>, - cursor: isize, ) -> Response { - autocomplete(world, doc, source, source.cursor(cursor), true) + let world = world.acquire(); + let world = world.borrow(); + let (source, cursor) = pos.resolve(world); + autocomplete(world, doc, &source, cursor, true) } #[test] fn test_autocomplete_hash_expr() { - test("#i", 2).must_include(["int", "if conditional"]); + test("#i", -1).must_include(["int", "if conditional"]); } #[test] fn test_autocomplete_array_method() { - test("#().", 4).must_include(["insert", "remove", "len", "all"]); - test("#{ let x = (1, 2, 3); x. }", -2).must_include(["at", "push", "pop"]); + test("#().", -1).must_include(["insert", "remove", "len", "all"]); + test("#{ let x = (1, 2, 3); x. }", -3).must_include(["at", "push", "pop"]); } /// Test that extra space before '.' is handled correctly. #[test] fn test_autocomplete_whitespace() { - test("#() .", 5).must_exclude(["insert", "remove", "len", "all"]); - test("#{() .}", 6).must_include(["insert", "remove", "len", "all"]); - test("#() .a", 6).must_exclude(["insert", "remove", "len", "all"]); - test("#{() .a}", 7).must_include(["at", "any", "all"]); + test("#() .", -1).must_exclude(["insert", "remove", "len", "all"]); + test("#{() .}", -2).must_include(["insert", "remove", "len", "all"]); + test("#() .a", -1).must_exclude(["insert", "remove", "len", "all"]); + test("#{() .a}", -2).must_include(["at", "any", "all"]); } /// Test that the `before_window` doesn't slice into invalid byte /// boundaries. #[test] fn test_autocomplete_before_window_char_boundary() { - test("😀😀 #text(font: \"\")", -2); + test("😀😀 #text(font: \"\")", -3); } /// Ensure that autocompletion for `#cite(|)` completes bibligraphy labels, @@ -1655,7 +1644,7 @@ mod tests { let end = world.main.len_bytes(); world.main.edit(end..end, " #cite()"); - test_full(&world, &world.main, doc.as_ref(), -1) + test_with_doc(&world, -2, doc.as_ref()) .must_include(["netwok", "glacier-melt", "supplement"]) .must_exclude(["bib"]); } @@ -1679,13 +1668,13 @@ mod tests { #[test] fn test_autocomplete_positional_param() { // No string given yet. - test("#numbering()", -1).must_include(["string", "integer"]); + test("#numbering()", -2).must_include(["string", "integer"]); // String is already given. - test("#numbering(\"foo\", )", -1) + test("#numbering(\"foo\", )", -2) .must_include(["integer"]) .must_exclude(["string"]); // Integer is already given, but numbering is variadic. - test("#numbering(\"foo\", 1, )", -1) + test("#numbering(\"foo\", 1, )", -2) .must_include(["integer"]) .must_exclude(["string"]); } @@ -1700,14 +1689,14 @@ mod tests { "#let clrs = (a: red, b: blue); #let nums = (a: 1, b: 2)", ); - test_with_world(&world, -1) + test(&world, -2) .must_include(["clrs", "aqua"]) .must_exclude(["nums", "a", "b"]); } #[test] fn test_autocomplete_packages() { - test("#import \"@\"", -1).must_include([q!("@preview/example:0.1.0")]); + test("#import \"@\"", -2).must_include([q!("@preview/example:0.1.0")]); } #[test] @@ -1721,29 +1710,28 @@ mod tests { .with_asset_at("assets/rhino.png", "rhino.png") .with_asset_at("data/example.csv", "example.csv"); - test_with_path(&world, "main.typ", -1) + test(&world, -2) .must_include([q!("content/a.typ"), q!("content/b.typ"), q!("utils.typ")]) .must_exclude([q!("assets/tiger.jpg")]); - test_with_path(&world, "content/c.typ", -1) + test(&world, ("content/c.typ", -2)) .must_include([q!("../main.typ"), q!("a.typ"), q!("b.typ")]) .must_exclude([q!("c.typ")]); - test_with_path(&world, "content/a.typ", -1) + test(&world, ("content/a.typ", -2)) .must_include([q!("../assets/tiger.jpg"), q!("../assets/rhino.png")]) .must_exclude([q!("../data/example.csv"), q!("b.typ")]); - test_with_path(&world, "content/b.typ", -2) - .must_include([q!("../data/example.csv")]); + test(&world, ("content/b.typ", -3)).must_include([q!("../data/example.csv")]); } #[test] fn test_autocomplete_figure_snippets() { - test("#figure()", -1) + test("#figure()", -2) .must_apply("image", "image(\"${}\"),") .must_apply("table", "table(\n ${}\n),"); - test("#figure(cap)", -1).must_apply("caption", "caption: [${}]"); + test("#figure(cap)", -2).must_apply("caption", "caption: [${}]"); } #[test] @@ -1752,10 +1740,10 @@ mod tests { .with_source("second.typ", "#import \"other.typ\": th") .with_source("other.typ", "#let this = 1; #let that = 2"); - test_with_path(&world, "main.typ", 21) + test(&world, ("main.typ", 21)) .must_include(["*", "this", "that"]) .must_exclude(["figure"]); - test_with_path(&world, "second.typ", 23) + test(&world, ("second.typ", 23)) .must_include(["this", "that"]) .must_exclude(["*", "figure"]); } diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs index c789430a2..31fb9e34e 100644 --- a/crates/typst-ide/src/definition.rs +++ b/crates/typst-ide/src/definition.rs @@ -86,6 +86,7 @@ pub fn definition( #[cfg(test)] mod tests { + use std::borrow::Borrow; use std::ops::Range; use typst::foundations::{IntoValue, NativeElement}; @@ -93,7 +94,7 @@ mod tests { use typst::WorldExt; use super::{definition, Definition}; - use crate::tests::{SourceExt, TestWorld}; + use crate::tests::{FilePos, TestWorld, WorldLike}; type Response = (TestWorld, Option); @@ -132,23 +133,19 @@ mod tests { } #[track_caller] - fn test(text: &str, cursor: isize, side: Side) -> Response { - let world = TestWorld::new(text); - test_with_world(world, cursor, side) - } - - #[track_caller] - fn test_with_world(world: TestWorld, cursor: isize, side: Side) -> Response { - let doc = typst::compile(&world).output.ok(); - let source = &world.main; - let def = definition(&world, doc.as_ref(), source, source.cursor(cursor), side); - (world, def) + fn test(world: impl WorldLike, pos: impl FilePos, side: Side) -> Response { + let world = world.acquire(); + let world = world.borrow(); + let doc = typst::compile(world).output.ok(); + let (source, cursor) = pos.resolve(world); + let def = definition(world, doc.as_ref(), &source, cursor, side); + (world.clone(), def) } #[test] fn test_definition_let() { - test("#let x; #x", 9, Side::After).must_be_at("main.typ", 5..6); - test("#let x() = {}; #x", 16, Side::After).must_be_at("main.typ", 5..6); + test("#let x; #x", -2, Side::After).must_be_at("main.typ", 5..6); + test("#let x() = {}; #x", -2, Side::After).must_be_at("main.typ", 5..6); } #[test] @@ -158,33 +155,33 @@ mod tests { // The span is at the args here because that's what the function value's // span is. Not ideal, but also not too big of a big deal. - test_with_world(world, -1, Side::Before).must_be_at("other.typ", 8..11); + test(&world, -2, Side::Before).must_be_at("other.typ", 8..11); } #[test] fn test_definition_cross_file() { let world = TestWorld::new("#import \"other.typ\": x; #x") .with_source("other.typ", "#let x = 1"); - test_with_world(world, -1, Side::After).must_be_at("other.typ", 5..6); + test(&world, -2, Side::After).must_be_at("other.typ", 5..6); } #[test] fn test_definition_import() { let world = TestWorld::new("#import \"other.typ\" as o: x") .with_source("other.typ", "#let x = 1"); - test_with_world(world, 14, Side::Before).must_be_at("other.typ", 0..0); + test(&world, 14, Side::Before).must_be_at("other.typ", 0..0); } #[test] fn test_definition_include() { let world = TestWorld::new("#include \"other.typ\"") .with_source("other.typ", "Hello there"); - test_with_world(world, 14, Side::Before).must_be_at("other.typ", 0..0); + test(&world, 14, Side::Before).must_be_at("other.typ", 0..0); } #[test] fn test_definition_ref() { - test("#figure[] See @hi", 21, Side::After).must_be_at("main.typ", 1..9); + test("#figure[] See @hi", -2, Side::After).must_be_at("main.typ", 1..9); } #[test] diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index ba62b0ab9..ed74df226 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -182,12 +182,13 @@ mod tests { //! )) //! ``` + use std::borrow::Borrow; use std::num::NonZeroUsize; use typst::layout::{Abs, Point, Position}; use super::{jump_from_click, jump_from_cursor, Jump}; - use crate::tests::TestWorld; + use crate::tests::{FilePos, TestWorld, WorldLike}; fn point(x: f64, y: f64) -> Point { Point::new(Abs::pt(x), Abs::pt(y)) @@ -211,10 +212,11 @@ mod tests { } #[track_caller] - fn test_click(text: &str, click: Point, expected: Option) { - let world = TestWorld::new(text); - let doc = typst::compile(&world).output.unwrap(); - let jump = jump_from_click(&world, &doc, &doc.pages[0].frame, click); + fn test_click(world: impl WorldLike, click: Point, expected: Option) { + let world = world.acquire(); + let world = world.borrow(); + let doc = typst::compile(world).output.unwrap(); + let jump = jump_from_click(world, &doc, &doc.pages[0].frame, click); if let (Some(Jump::Position(pos)), Some(Jump::Position(expected))) = (&jump, &expected) { @@ -227,10 +229,12 @@ mod tests { } #[track_caller] - fn test_cursor(text: &str, cursor: usize, expected: Option) { - let world = TestWorld::new(text); - let doc = typst::compile(&world).output.unwrap(); - let pos = jump_from_cursor(&doc, &world.main, cursor); + fn test_cursor(world: impl WorldLike, pos: impl FilePos, expected: Option) { + let world = world.acquire(); + let world = world.borrow(); + let doc = typst::compile(world).output.unwrap(); + let (source, cursor) = pos.resolve(world); + let pos = jump_from_cursor(&doc, &source, cursor); assert_eq!(!pos.is_empty(), expected.is_some()); if let (Some(pos), Some(expected)) = (pos.first(), expected) { assert_eq!(pos.page, expected.page); diff --git a/crates/typst-ide/src/matchers.rs b/crates/typst-ide/src/matchers.rs index 18262f701..d02eb2a95 100644 --- a/crates/typst-ide/src/matchers.rs +++ b/crates/typst-ide/src/matchers.rs @@ -266,53 +266,79 @@ pub enum DerefTarget<'a> { #[cfg(test)] mod tests { + use std::borrow::Borrow; + + use ecow::EcoString; use typst::syntax::{LinkedNode, Side}; - use crate::{named_items, tests::TestWorld}; + use super::named_items; + use crate::tests::{FilePos, WorldLike}; + + type Response = Vec; + + trait ResponseExt { + fn must_include<'a>(&self, includes: impl IntoIterator) -> &Self; + fn must_exclude<'a>(&self, excludes: impl IntoIterator) -> &Self; + } + + impl ResponseExt for Response { + #[track_caller] + fn must_include<'a>(&self, includes: impl IntoIterator) -> &Self { + for item in includes { + assert!( + self.iter().any(|v| v == item), + "{item:?} was not contained in {self:?}", + ); + } + self + } + + #[track_caller] + fn must_exclude<'a>(&self, excludes: impl IntoIterator) -> &Self { + for item in excludes { + assert!( + !self.iter().any(|v| v == item), + "{item:?} was wrongly contained in {self:?}", + ); + } + self + } + } #[track_caller] - fn has_named_items(text: &str, cursor: usize, containing: &str) -> bool { - let world = TestWorld::new(text); - - let src = world.main.clone(); - let node = LinkedNode::new(src.root()); + fn test(world: impl WorldLike, pos: impl FilePos) -> Response { + let world = world.acquire(); + let world = world.borrow(); + let (source, cursor) = pos.resolve(world); + let node = LinkedNode::new(source.root()); let leaf = node.leaf_at(cursor, Side::After).unwrap(); - - let res = named_items(&world, leaf, |s| { - if containing == s.name() { - return Some(true); - } - - None + let mut items = vec![]; + named_items(world, leaf, |s| { + items.push(s.name().clone()); + None::<()> }); - - res.unwrap_or_default() + items } #[test] - fn test_simple_named_items() { - // Has named items - assert!(has_named_items(r#"#let a = 1;#let b = 2;"#, 8, "a")); - assert!(has_named_items(r#"#let a = 1;#let b = 2;"#, 15, "a")); - - // Doesn't have named items - assert!(!has_named_items(r#"#let a = 1;#let b = 2;"#, 8, "b")); + fn test_named_items_simple() { + let s = "#let a = 1;#let b = 2;"; + test(s, 8).must_include(["a"]).must_exclude(["b"]); + test(s, 15).must_include(["b"]); } #[test] - fn test_param_named_items() { - // Has named items - assert!(has_named_items(r#"#let f(a) = 1;#let b = 2;"#, 12, "a")); - assert!(has_named_items(r#"#let f(a: b) = 1;#let b = 2;"#, 15, "a")); + fn test_named_items_param() { + let pos = "#let f(a) = 1;#let b = 2;"; + test(pos, 12).must_include(["a"]); + test(pos, 19).must_include(["b", "f"]).must_exclude(["a"]); - // Doesn't have named items - assert!(!has_named_items(r#"#let f(a) = 1;#let b = 2;"#, 19, "a")); - assert!(!has_named_items(r#"#let f(a: b) = 1;#let b = 2;"#, 15, "b")); + let named = "#let f(a: b) = 1;#let b = 2;"; + test(named, 15).must_include(["a", "f"]).must_exclude(["b"]); } #[test] - fn test_import_named_items() { - // Cannot test much. - assert!(has_named_items(r#"#import "foo.typ": a; #(a);"#, 24, "a")); + fn test_named_items_import() { + test("#import \"foo.typ\": a; #(a);", 2).must_include(["a"]); } } diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 5a73fa375..f41808dac 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -1,4 +1,6 @@ +use std::borrow::Borrow; use std::collections::HashMap; +use std::sync::Arc; use ecow::EcoString; use typst::diag::{FileError, FileResult}; @@ -13,10 +15,10 @@ use typst::{Library, World}; use crate::IdeWorld; /// A world for IDE testing. +#[derive(Clone)] pub struct TestWorld { pub main: Source, - assets: HashMap, - sources: HashMap, + files: Arc, base: &'static TestBase, } @@ -29,8 +31,7 @@ impl TestWorld { let main = Source::new(Self::main_id(), text.into()); Self { main, - assets: HashMap::new(), - sources: HashMap::new(), + files: Arc::new(TestFiles::default()), base: singleton!(TestBase, TestBase::default()), } } @@ -39,7 +40,7 @@ impl TestWorld { pub fn with_source(mut self, path: &str, text: &str) -> Self { let id = FileId::new(None, VirtualPath::new(path)); let source = Source::new(id, text.into()); - self.sources.insert(id, source); + Arc::make_mut(&mut self.files).sources.insert(id, source); self } @@ -55,7 +56,7 @@ impl TestWorld { let id = FileId::new(None, VirtualPath::new(path)); let data = typst_dev_assets::get_by_name(filename).unwrap(); let bytes = Bytes::from_static(data); - self.assets.insert(id, bytes); + Arc::make_mut(&mut self.files).assets.insert(id, bytes); self } @@ -81,7 +82,7 @@ impl World for TestWorld { fn source(&self, id: FileId) -> FileResult { if id == self.main.id() { Ok(self.main.clone()) - } else if let Some(source) = self.sources.get(&id) { + } else if let Some(source) = self.files.sources.get(&id) { Ok(source.clone()) } else { Err(FileError::NotFound(id.vpath().as_rootless_path().into())) @@ -89,7 +90,7 @@ impl World for TestWorld { } fn file(&self, id: FileId) -> FileResult { - match self.assets.get(&id) { + match self.files.assets.get(&id) { Some(bytes) => Ok(bytes.clone()), None => Err(FileError::NotFound(id.vpath().as_rootless_path().into())), } @@ -111,8 +112,8 @@ impl IdeWorld for TestWorld { fn files(&self) -> Vec { std::iter::once(self.main.id()) - .chain(self.sources.keys().copied()) - .chain(self.assets.keys().copied()) + .chain(self.files.sources.keys().copied()) + .chain(self.files.assets.keys().copied()) .collect() } @@ -133,20 +134,11 @@ impl IdeWorld for TestWorld { } } -/// Extra methods for [`Source`]. -pub trait SourceExt { - /// Negative cursors index from the back. - fn cursor(&self, cursor: isize) -> usize; -} - -impl SourceExt for Source { - fn cursor(&self, cursor: isize) -> usize { - if cursor < 0 { - self.len_bytes().checked_add_signed(cursor).unwrap() - } else { - cursor as usize - } - } +/// Test-specific files. +#[derive(Default, Clone)] +struct TestFiles { + assets: HashMap, + sources: HashMap, } /// Shared foundation of all test worlds. @@ -186,3 +178,58 @@ fn library() -> Library { lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into()))); lib } + +/// The input to a test: Either just a string or a full `TestWorld`. +pub trait WorldLike { + type World: Borrow; + + fn acquire(self) -> Self::World; +} + +impl<'a> WorldLike for &'a TestWorld { + type World = &'a TestWorld; + + fn acquire(self) -> Self::World { + self + } +} + +impl WorldLike for &str { + type World = TestWorld; + + fn acquire(self) -> Self::World { + TestWorld::new(self) + } +} + +/// Specifies a position in a file for a test. +pub trait FilePos { + fn resolve(self, world: &TestWorld) -> (Source, usize); +} + +impl FilePos for isize { + #[track_caller] + fn resolve(self, world: &TestWorld) -> (Source, usize) { + (world.main.clone(), cursor(&world.main, self)) + } +} + +impl FilePos for (&str, isize) { + #[track_caller] + fn resolve(self, world: &TestWorld) -> (Source, usize) { + let id = FileId::new(None, VirtualPath::new(self.0)); + let source = world.source(id).unwrap(); + let cursor = cursor(&source, self.1); + (source, cursor) + } +} + +/// Resolve a signed index (negative from the back) to a unsigned index. +#[track_caller] +fn cursor(source: &Source, cursor: isize) -> usize { + if cursor < 0 { + source.len_bytes().checked_add_signed(cursor + 1).unwrap() + } else { + cursor as usize + } +} diff --git a/crates/typst-ide/src/tooltip.rs b/crates/typst-ide/src/tooltip.rs index 4eaaeda1f..99ae0620b 100644 --- a/crates/typst-ide/src/tooltip.rs +++ b/crates/typst-ide/src/tooltip.rs @@ -274,10 +274,12 @@ fn font_tooltip(world: &dyn IdeWorld, leaf: &LinkedNode) -> Option { #[cfg(test)] mod tests { + use std::borrow::Borrow; + use typst::syntax::Side; use super::{tooltip, Tooltip}; - use crate::tests::{SourceExt, TestWorld}; + use crate::tests::{FilePos, TestWorld, WorldLike}; type Response = Option; @@ -308,21 +310,17 @@ mod tests { } #[track_caller] - fn test(text: &str, cursor: isize, side: Side) -> Response { - let world = TestWorld::new(text); - test_with_world(&world, cursor, side) - } - - #[track_caller] - fn test_with_world(world: &TestWorld, cursor: isize, side: Side) -> Response { - let source = &world.main; - let doc = typst::compile(&world).output.ok(); - tooltip(world, doc.as_ref(), source, source.cursor(cursor), side) + fn test(world: impl WorldLike, pos: impl FilePos, side: Side) -> Response { + let world = world.acquire(); + let world = world.borrow(); + let (source, cursor) = pos.resolve(world); + let doc = typst::compile(world).output.ok(); + tooltip(world, doc.as_ref(), &source, cursor, side) } #[test] fn test_tooltip() { - test("#let x = 1 + 2", 14, Side::After).must_be_none(); + test("#let x = 1 + 2", -1, Side::After).must_be_none(); test("#let x = 1 + 2", 5, Side::After).must_be_code("3"); test("#let x = 1 + 2", 6, Side::Before).must_be_code("3"); test("#let x = 1 + 2", 6, Side::Before).must_be_code("3"); @@ -330,7 +328,7 @@ mod tests { #[test] fn test_tooltip_empty_contextual() { - test("#{context}", 10, Side::Before).must_be_code("context()"); + test("#{context}", -1, Side::Before).must_be_code("context()"); } #[test] @@ -358,8 +356,7 @@ mod tests { fn test_tooltip_star_import() { let world = TestWorld::new("#import \"other.typ\": *") .with_source("other.typ", "#let (a, b, c) = (1, 2, 3)"); - test_with_world(&world, 21, Side::Before).must_be_none(); - test_with_world(&world, 21, Side::After) - .must_be_text("This star imports `a`, `b`, and `c`"); + test(&world, -2, Side::Before).must_be_none(); + test(&world, -2, Side::After).must_be_text("This star imports `a`, `b`, and `c`"); } }