mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
feature fixes
This commit is contained in:
1 parent
586ee946d0
commit
c1c47bc513
63 files changed
+12083
-229
No files matched your search
@@ -0,0 +1,481 @@
|
||||
// An export over a real folder of markdown, asked the same question src-tauri/tests/no_write_on_open.rs
|
||||
// asks of a save: did anything in the folder move?
|
||||
//
|
||||
// src-tauri/tests/pdf.rs already covers what the compiler refuses and what it survives, against a
|
||||
// TempDir with two files in it. That is the right shape for a question about diagnostics and the
|
||||
// wrong shape for a question about bytes: a bag of files in a temporary folder cannot say that a
|
||||
// folder somebody would plausibly have opened comes back from `git status` with no lines in it. So
|
||||
// this suite runs against the same generated git repository the no-write suite does, built by
|
||||
// tests/support/notes_repo.rs, and the oracle is the same one: `git status --porcelain`, plus a
|
||||
// stat and content snapshot of every path under the root so that a rewrite with identical bytes is
|
||||
// still caught.
|
||||
//
|
||||
// The tests share that one folder and `pristine()` resets it, so they hold a lock rather than
|
||||
// needing `--test-threads=1`. Running the binary under any thread count is correct.
|
||||
//
|
||||
// The sharp question is at the bottom, and it is about `pdf_write` rather than about the compiler.
|
||||
// `pdf_write` skips the open-roots guard on purpose, because its path came from a native save panel
|
||||
// and is the user's own choice of destination. What that also means is that it will write to
|
||||
// whatever it is given, and nothing on either side of the boundary checks that the destination is
|
||||
// not one of the user's own documents. `a_save_panel_pointed_at_a_document_is_refused` is what
|
||||
// happens then, written down so that it is a fact somebody decided rather than one nobody noticed.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use margin_docs_lib::dto::ImageInput;
|
||||
use margin_docs_lib::pdf::{compile, pdf_write};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[path = "support/notes_repo.rs"]
|
||||
mod notes_repo;
|
||||
|
||||
// ---------------------------------------------------------------- fixture
|
||||
|
||||
/// One test in the folder at a time. The suite shares a single repository and `pristine()` throws
|
||||
/// away whatever the last test did to it, so this is what makes the sharing safe under `cargo
|
||||
/// test`'s default thread count instead of a flag in CI that somebody has to remember.
|
||||
static FIXTURE: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Back to the committed state, with the lock held for as long as the guard lives.
|
||||
///
|
||||
/// A poisoned lock is taken anyway: it means an earlier test panicked, which is a failure that has
|
||||
/// already been reported, and refusing to run the rest of the suite on top of it turns one red test
|
||||
/// into a file of them.
|
||||
fn pristine() -> (MutexGuard<'static, ()>, PathBuf) {
|
||||
let guard = FIXTURE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let root = notes_repo::path().to_path_buf();
|
||||
assert!(
|
||||
root.join(".git").is_dir(),
|
||||
"the fixture repo is missing: {}",
|
||||
root.display()
|
||||
);
|
||||
notes_repo::git(&["reset", "--hard", "-q"]);
|
||||
notes_repo::git(&["clean", "-fdq"]);
|
||||
let status = notes_repo::git(&["status", "--porcelain"]);
|
||||
assert!(
|
||||
status.is_empty(),
|
||||
"the fixture repo did not start clean:\n{status}"
|
||||
);
|
||||
(guard, root)
|
||||
}
|
||||
|
||||
fn git_status() -> String {
|
||||
notes_repo::git(&["status", "--porcelain"])
|
||||
}
|
||||
|
||||
fn quoted(status: &str) -> String {
|
||||
if status.is_empty() {
|
||||
" <empty: working tree clean>".to_string()
|
||||
} else {
|
||||
status
|
||||
.lines()
|
||||
.map(|line| format!(" {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_clean(label: &str) {
|
||||
let status = git_status();
|
||||
println!(" [{label}] git status --porcelain:\n{}", quoted(&status));
|
||||
assert!(status.is_empty(), "{label} left git dirty:\n{status}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshots
|
||||
|
||||
/// Enough of a file to notice a rewrite that put the same bytes back. `git status` cannot see one
|
||||
/// of those and it is exactly what an exporter tidying up after itself would leave.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
struct Stamp {
|
||||
kind: &'static str,
|
||||
len: u64,
|
||||
mtime: (i64, i64),
|
||||
ino: u64,
|
||||
/// Content hash, for everything outside the vendored `node_modules`, where the stat is already
|
||||
/// conclusive and hashing 13,000 files on every snapshot is not worth the second it costs.
|
||||
hash: Option<u64>,
|
||||
}
|
||||
|
||||
type Snapshot = BTreeMap<String, Stamp>;
|
||||
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in bytes {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Every path under `root`, dotfiles, .git and node_modules included.
|
||||
fn snapshot(root: &Path) -> Snapshot {
|
||||
let mut out = Snapshot::new();
|
||||
walk(root, root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let Ok(meta) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
let kind = if meta.is_dir() {
|
||||
"dir"
|
||||
} else if meta.is_symlink() {
|
||||
"link"
|
||||
} else {
|
||||
"file"
|
||||
};
|
||||
let cheap = rel.starts_with("node_modules/") || rel.starts_with(".git/");
|
||||
out.insert(
|
||||
rel,
|
||||
Stamp {
|
||||
kind,
|
||||
len: meta.len(),
|
||||
mtime: (meta.mtime(), meta.mtime_nsec()),
|
||||
ino: meta.ino(),
|
||||
hash: if kind == "file" && !cheap {
|
||||
fs::read(&path).ok().map(|bytes| fnv1a(&bytes))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
);
|
||||
if meta.is_dir() {
|
||||
walk(root, &path, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What moved between two snapshots, ignoring nothing.
|
||||
fn changes(before: &Snapshot, after: &Snapshot) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
let mut changed = Vec::new();
|
||||
for (path, stamp) in after {
|
||||
match before.get(path) {
|
||||
None => added.push(path.clone()),
|
||||
Some(was) if was != stamp => {
|
||||
changed.push(format!("{path}\n was {was:?}\n now {stamp:?}"))
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for path in before.keys() {
|
||||
if !after.contains_key(path) {
|
||||
removed.push(path.clone());
|
||||
}
|
||||
}
|
||||
(added, removed, changed)
|
||||
}
|
||||
|
||||
fn assert_untouched(label: &str, before: &Snapshot, after: &Snapshot) {
|
||||
let (added, removed, changed) = changes(before, after);
|
||||
assert!(
|
||||
added.is_empty() && removed.is_empty() && changed.is_empty(),
|
||||
"{label} touched the folder\n added: {added:?}\n removed: {removed:?}\n changed:\n {}",
|
||||
changed.join("\n ")
|
||||
);
|
||||
println!(" [{label}] {} paths, none touched", before.len());
|
||||
}
|
||||
|
||||
/// The same, allowing exactly the paths named to appear and the folders holding them to have been
|
||||
/// written into. A PDF exported next to the documents is a new file, and a new file is a new mtime
|
||||
/// on its parent directory; nothing else is allowed to move.
|
||||
fn assert_only_added(label: &str, before: &Snapshot, after: &Snapshot, expected: &[&str]) {
|
||||
let (added, removed, changed) = changes(before, after);
|
||||
assert_eq!(added, expected, "{label} added something unexpected");
|
||||
assert!(removed.is_empty(), "{label} removed {removed:?}");
|
||||
let parents: Vec<String> = expected
|
||||
.iter()
|
||||
.map(|p| match p.rfind('/') {
|
||||
Some(at) => p[..at].to_string(),
|
||||
None => String::new(),
|
||||
})
|
||||
.collect();
|
||||
let unexpected: Vec<&String> = changed
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
let path = entry.lines().next().unwrap_or_default();
|
||||
!parents.iter().any(|parent| parent == path)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
unexpected.is_empty(),
|
||||
"{label} changed more than the folder it wrote into:\n {}",
|
||||
unexpected
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ")
|
||||
);
|
||||
println!(" [{label}] added {expected:?} and moved nothing else");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the document
|
||||
|
||||
/// The head of what src/export/typst.ts writes: the mitex import that is the contract between the
|
||||
/// converter and src-tauri/src/pdf.rs, and set rules naming no font, because which faces exist is
|
||||
/// the backend's business.
|
||||
const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi
|
||||
#set document(title: "Handbook")
|
||||
#set page(paper: "a4", margin: 2cm)
|
||||
#set text(size: 11pt, lang: "en")
|
||||
"#;
|
||||
|
||||
/// A drawn mermaid diagram as it crosses the boundary: SVG from the webview, with no file behind it.
|
||||
const DIAGRAM: &[u8] =
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="8"><rect width="16" height="8" fill="#456"/></svg>"##;
|
||||
|
||||
/// The whole export, as the converter would have written it for a document in this folder: a
|
||||
/// picture that is a real file inside the open root, a drawn diagram that is bytes, a table and a
|
||||
/// formula. Every one of those is a path the exporter does extra work on, and three of them mean
|
||||
/// the compiler opening something.
|
||||
fn document_from(root: &Path) -> (String, Vec<ImageInput>) {
|
||||
let picture = root.join("assets/logo.png");
|
||||
assert!(picture.is_file(), "the fixture has an asset to point at");
|
||||
|
||||
let source = format!(
|
||||
"{PREAMBLE}
|
||||
= The handbook
|
||||
|
||||
Prose, then a picture that is a file in the open folder.
|
||||
|
||||
#image(\"{}\")
|
||||
|
||||
#figure(image(\"/inline/diagram-1.svg\"))
|
||||
|
||||
#table(columns: 2, [region], [total], [north], [12])
|
||||
|
||||
Inline #mi(\"a^2 + b^2 = c^2\") and a display one:
|
||||
|
||||
#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\")
|
||||
",
|
||||
picture.display()
|
||||
);
|
||||
|
||||
let images = vec![
|
||||
ImageInput {
|
||||
path: picture.to_string_lossy().into_owned(),
|
||||
data: None,
|
||||
},
|
||||
ImageInput {
|
||||
path: "/inline/diagram-1.svg".to_string(),
|
||||
data: Some(STANDARD.encode(DIAGRAM)),
|
||||
},
|
||||
];
|
||||
(source, images)
|
||||
}
|
||||
|
||||
fn roots_of(root: &Path) -> Vec<String> {
|
||||
vec![root.to_string_lossy().into_owned()]
|
||||
}
|
||||
|
||||
// ================================================================ compiling
|
||||
|
||||
#[test]
|
||||
fn compiling_a_document_out_of_a_real_folder_writes_nothing_in_it() {
|
||||
let (_lock, root) = pristine();
|
||||
let (source, images) = document_from(&root);
|
||||
|
||||
let before = snapshot(&root);
|
||||
let (bytes, warnings) =
|
||||
compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||
let after = snapshot(&root);
|
||||
|
||||
assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF");
|
||||
// The picture really was read off disk rather than worked around, so the one step of the
|
||||
// compile that opens a file in the user's folder is a step this test actually took.
|
||||
let worked_around: Vec<&str> = warnings
|
||||
.iter()
|
||||
.filter(|w| w.kind == "image")
|
||||
.map(|w| w.message.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
worked_around.is_empty(),
|
||||
"the image in the open folder was read: {worked_around:?}"
|
||||
);
|
||||
|
||||
assert_untouched("a compile", &before, &after);
|
||||
assert_clean("a compile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiling_the_same_folder_ten_times_over_still_writes_nothing() {
|
||||
// Once could be a compile that failed early and touched nothing because it did nothing. Ten
|
||||
// laps, with the fonts loaded and the images opened every time, is the shape of the thing the
|
||||
// user actually does: export, read it, change a line, export again.
|
||||
let (_lock, root) = pristine();
|
||||
let before = snapshot(&root);
|
||||
|
||||
for lap in 0..10 {
|
||||
let (source, images) = document_from(&root);
|
||||
let (bytes, _) =
|
||||
compile(source, &images, &roots_of(&root)).unwrap_or_else(|e| panic!("lap {lap}: {e}"));
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
}
|
||||
|
||||
assert_untouched("ten compiles", &before, &snapshot(&root));
|
||||
assert_clean("ten compiles");
|
||||
}
|
||||
|
||||
// ================================================================ writing
|
||||
|
||||
#[test]
|
||||
fn exporting_to_a_folder_of_its_own_leaves_the_documents_alone() {
|
||||
// The ordinary export: the panel points somewhere outside the notes folder, which is where a
|
||||
// PDF belongs. Nothing in the folder the document came from has any business moving, and the
|
||||
// whole of `git status` is the evidence.
|
||||
let (_lock, root) = pristine();
|
||||
let (source, images) = document_from(&root);
|
||||
let elsewhere = TempDir::new().expect("a temp dir");
|
||||
let target = elsewhere.path().join("The handbook.pdf");
|
||||
|
||||
let before = snapshot(&root);
|
||||
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||
pdf_write(target.to_string_lossy().into_owned(), bytes.clone()).expect("the panel's path");
|
||||
let after = snapshot(&root);
|
||||
|
||||
assert_eq!(fs::read(&target).expect("the PDF is there"), bytes);
|
||||
assert_untouched("an export to another folder", &before, &after);
|
||||
assert_clean("an export to another folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_beside_the_documents_adds_the_pdf_and_moves_nothing_else() {
|
||||
// The other ordinary export, and the one that cannot leave `git status` empty: a PDF written
|
||||
// next to the document is a new file in the folder and shows up as one. What matters is that
|
||||
// it is the only line, and that no document was rewritten to put it there.
|
||||
let (_lock, root) = pristine();
|
||||
let (source, images) = document_from(&root);
|
||||
let target = root.join("docs/guides/setup.pdf");
|
||||
|
||||
let before = snapshot(&root);
|
||||
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||
pdf_write(target.to_string_lossy().into_owned(), bytes).expect("a path inside the root");
|
||||
let after = snapshot(&root);
|
||||
|
||||
assert_only_added(
|
||||
"an export into the folder",
|
||||
&before,
|
||||
&after,
|
||||
&["docs/guides/setup.pdf"],
|
||||
);
|
||||
|
||||
let status = git_status();
|
||||
println!(
|
||||
"git status --porcelain after an export into the folder:\n{}",
|
||||
quoted(&status)
|
||||
);
|
||||
assert_eq!(
|
||||
status.lines().collect::<Vec<_>>(),
|
||||
vec!["?? docs/guides/setup.pdf"],
|
||||
"the PDF is the only thing git can see"
|
||||
);
|
||||
|
||||
// And the atomic write left nothing of its own behind: the temp file it renames through is
|
||||
// gone, so there is no `.setup.pdf.tmp` for the next `git add -A` to sweep up.
|
||||
let strays: Vec<String> = fs::read_dir(root.join("docs/guides"))
|
||||
.expect("the guides folder")
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| !name.ends_with(".md") && name != "setup.pdf")
|
||||
.collect();
|
||||
assert!(strays.is_empty(), "the write left {strays:?} behind");
|
||||
}
|
||||
|
||||
// ================================================================ the guard that is not there
|
||||
|
||||
#[test]
|
||||
fn a_save_panel_pointed_at_a_document_is_refused() {
|
||||
// This test was written the other way up, pinning the overwrite as a fact somebody had decided.
|
||||
// Nobody had: it was the one way an export could destroy markdown, and it needed only a user who
|
||||
// reached the save panel and typed a `.md` name into it. It has not been seen because the panel
|
||||
// carries a PDF filter and AppKit usually appends `.pdf` to a name typed with another extension,
|
||||
// which is the panel being careful rather than this module, and the promise that the editor
|
||||
// never writes a file the user did not edit is not the panel's to keep.
|
||||
//
|
||||
// So `pdf_write` now refuses a destination that already holds one of the user's documents, and
|
||||
// this is the same test rewritten to the truth that replaced it. Everything about the rest of
|
||||
// the decision still stands: the root guard is still off this path on purpose, which is what
|
||||
// `pdf_write_will_also_write_outside_every_open_folder` below is about.
|
||||
let (_lock, root) = pristine();
|
||||
let (source, images) = document_from(&root);
|
||||
let document = root.join("docs/design.md");
|
||||
let markdown = fs::read(&document).expect("a document to aim at");
|
||||
assert!(
|
||||
markdown.starts_with(b"#") || markdown.len() > 100,
|
||||
"the fixture document has real markdown in it"
|
||||
);
|
||||
|
||||
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
|
||||
let answer = pdf_write(document.to_string_lossy().into_owned(), bytes.clone());
|
||||
|
||||
let message = answer.expect_err("a document is not a place to put a PDF");
|
||||
assert!(
|
||||
message.contains("design.md") && message.contains("document"),
|
||||
"the refusal names the file and says why: {message}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&document).expect("the document is still a file"),
|
||||
markdown,
|
||||
"the document is untouched, byte for byte"
|
||||
);
|
||||
|
||||
let status = git_status();
|
||||
assert_eq!(status, "", "the folder is clean:\n{}", quoted(&status));
|
||||
|
||||
// A .txt is a document too, and a .pdf that is already there is not: replacing one is what a
|
||||
// save panel is for, and it has already asked.
|
||||
let notes = root.join("docs/notes.txt");
|
||||
fs::write(¬es, b"a plain text document\n").expect("a text file");
|
||||
let again = bytes;
|
||||
pdf_write(notes.to_string_lossy().into_owned(), again.clone())
|
||||
.expect_err("a .txt is a document as much as a .md is");
|
||||
|
||||
let existing = root.join("docs/already.pdf");
|
||||
fs::write(&existing, b"%PDF-1.7 an older export\n").expect("an older PDF");
|
||||
pdf_write(existing.to_string_lossy().into_owned(), again.clone())
|
||||
.expect("replacing a PDF is what the panel asked about");
|
||||
assert_eq!(fs::read(&existing).expect("the newer export"), again);
|
||||
|
||||
let fresh = root.join("docs/never-existed.md");
|
||||
pdf_write(fresh.to_string_lossy().into_owned(), again)
|
||||
.expect("a name nothing holds destroys nothing, however odd it looks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_write_will_also_write_outside_every_open_folder() {
|
||||
// The half of the same decision that is intended, kept next to the half that is not so the two
|
||||
// are read together. A PDF belongs on the Desktop or in Downloads far more often than it
|
||||
// belongs in the notes folder, so `resolve_in_roots` is not on this path and must not be. The
|
||||
// fix for the test above, if there is one, is not a root guard.
|
||||
let (_lock, root) = pristine();
|
||||
let elsewhere = TempDir::new().expect("a temp dir");
|
||||
let target = elsewhere.path().join("report.pdf");
|
||||
|
||||
pdf_write(
|
||||
target.to_string_lossy().into_owned(),
|
||||
b"%PDF-1.7\n".to_vec(),
|
||||
)
|
||||
.expect("somewhere the user never opened");
|
||||
|
||||
assert_eq!(fs::read(&target).expect("the PDF"), b"%PDF-1.7\n");
|
||||
assert_clean("a write outside every root");
|
||||
let _ = root;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// What the grammar checker promises the frontend, asserted against the real engine.
|
||||
//
|
||||
// Three things, and the first is the one that would be invisible everywhere else. Offsets are in
|
||||
// characters, and src/editor/proofing.ts adds a ProseMirror position to them without converting
|
||||
// anything, so an engine that counted bytes would draw its underlines further and further to the
|
||||
// left of the words they are about for every non-ASCII character earlier in the paragraph. That is
|
||||
// a bug nobody writing in English would ever see and everybody writing in French or Polish would
|
||||
// see immediately, and no other test in this repository is in a position to notice it: the Rust
|
||||
// suite otherwise asserts about files, and the browser suite talks to the fixture in
|
||||
// src/dev/mockIpc.ts rather than to Harper.
|
||||
//
|
||||
// The second is that Harper's own spell rule is off. It is turned off in src-tauri/src/grammar.rs because
|
||||
// NSSpellChecker already does the spelling and knows the user's own names and languages, and a
|
||||
// second dictionary underlining "Yoshinari" would be exactly the noise that gets a checker switched
|
||||
// off for good. Left on, everything would still be green: there would just be spelling issues
|
||||
// arriving through the grammar command, drawn in the grammar colour, with no Learn item on them.
|
||||
//
|
||||
// The third is that the engine survives being asked twice. It is a static built on first use and a
|
||||
// `LintGroup` that lints through `&mut self`, so a second call is the one that would find a lock
|
||||
// held or a state left dirty by the first.
|
||||
|
||||
use margin_docs_lib::dto::GrammarIssue;
|
||||
use margin_docs_lib::grammar::{grammar_available, grammar_check};
|
||||
|
||||
/// The text a lint is about, sliced the way the frontend slices it: by character.
|
||||
fn flagged(text: &str, issue: &GrammarIssue) -> String {
|
||||
text.chars()
|
||||
.skip(issue.start)
|
||||
.take(issue.end - issue.start)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn check(text: &str) -> Vec<GrammarIssue> {
|
||||
grammar_check(text.to_string()).expect("the checker answered")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_with_harper_in_it_says_so() {
|
||||
assert!(grammar_available().expect("availability answered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_word_is_found_and_can_be_corrected() {
|
||||
let issues = check("I put the the book down.");
|
||||
let repeat = issues
|
||||
.iter()
|
||||
.find(|issue| flagged("I put the the book down.", issue).contains("the the"))
|
||||
.expect("the repeated word was found");
|
||||
|
||||
assert!(!repeat.kind.is_empty(), "a lint carries the rule's category");
|
||||
assert!(!repeat.message.is_empty(), "and something to show the reader");
|
||||
assert!(
|
||||
repeat.suggestions.iter().any(|s| s.trim() == "the"),
|
||||
"with the correction on it: {:?}",
|
||||
repeat.suggestions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_are_characters_and_not_bytes() {
|
||||
// Every character before the mistake is three bytes in UTF-8, so a checker counting bytes would
|
||||
// report a span three times too far along and the assertion below would slice the wrong words.
|
||||
let text = "Zażółć gęślą jaźń, and then I put the the book down.";
|
||||
let issues = check(text);
|
||||
let repeat = issues
|
||||
.iter()
|
||||
.find(|issue| flagged(text, issue).contains("the the"))
|
||||
.expect("the repeated word was found after a run of non-ASCII text");
|
||||
|
||||
assert_eq!(flagged(text, repeat), "the the");
|
||||
assert!(
|
||||
repeat.end <= text.chars().count(),
|
||||
"and it ends inside the run it was told about"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_is_left_to_the_system_checker() {
|
||||
// Not a word in any dictionary, and Harper's curated one included. Nothing may come back about
|
||||
// it, because the only rule that would have is the one src-tauri/src/grammar.rs turns off.
|
||||
let text = "The flurbulent maglifter needs oiling.";
|
||||
for issue in check(text) {
|
||||
assert_ne!(
|
||||
issue.kind, "Spelling",
|
||||
"the grammar checker reported a spelling issue: {issue:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_say_about_nothing() {
|
||||
assert!(check("").is_empty());
|
||||
}
|
||||
|
||||
/// Harper flags two spaces between sentences as "French spaces", and src-tauri/src/grammar.rs drops every
|
||||
/// lint whose whole span is whitespace for the reasons written there: an underline nobody can see
|
||||
/// or click on, and, when the spaces are the ones src/editor/proofing.ts writes in place of an
|
||||
/// inline code span, an underline over the one thing that file went out of its way not to send.
|
||||
///
|
||||
/// These two inputs are the ones that reach the filter, so taking the filter out fails this test
|
||||
/// rather than leaving it green. What it cannot see is a future Harper that stops raising the lint
|
||||
/// at all, which would leave the guard unreached and this file none the wiser.
|
||||
#[test]
|
||||
fn a_lint_about_nothing_but_spaces_is_not_reported() {
|
||||
assert!(
|
||||
check("This is fine. Two spaces there.").is_empty(),
|
||||
"the two spaces between the sentences were reported"
|
||||
);
|
||||
// The shape src/editor/proofing.ts produces from a blanked inline code span.
|
||||
assert!(
|
||||
check("The value is nine.").is_empty(),
|
||||
"a blanked code span was reported as a run of spaces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engine_is_kept_and_answers_the_same_way_twice() {
|
||||
let text = "I put the the book down.";
|
||||
let first = check(text);
|
||||
let second = check(text);
|
||||
assert_eq!(first.len(), second.len());
|
||||
assert_eq!(
|
||||
first.first().map(|i| (i.start, i.end)),
|
||||
second.first().map(|i| (i.start, i.end))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// The exporter compiles a document nobody has checked, so the tests here are about what it refuses
|
||||
// and what it survives rather than about the typesetting: a formula reaches mitex instead of the
|
||||
// page as source, an image outside every open folder is never read, and neither a broken link nor a
|
||||
// formula the converter choked on costs the user the whole PDF.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use margin_docs_lib::dto::ImageInput;
|
||||
use margin_docs_lib::pdf::compile;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one
|
||||
/// the placeholder cannot be mistaken for.
|
||||
const PNG: &str =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGO8I2LDAANMcBZeDgA8PAE0qLfS9QAAAABJRU5ErkJggg==";
|
||||
|
||||
/// The shape of what src/export/typst.ts puts at the top of a document with a formula in it: the
|
||||
/// mitex import, which is the contract between the converter and this module, and set rules that
|
||||
/// name neither the monospace nor the maths face, because which of those exist is the backend's
|
||||
/// business and a family named hopefully is a warning per export about fonts nobody chose. A
|
||||
/// document set rule after the preamble pdf.rs prepends is part of what these fixtures prove.
|
||||
const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi
|
||||
#set document(title: "Fixture")
|
||||
#set page(paper: "a4", margin: 2cm)
|
||||
#set text(size: 11pt, lang: "en")
|
||||
"#;
|
||||
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
STANDARD.decode(PNG).unwrap()
|
||||
}
|
||||
|
||||
fn root() -> (TempDir, Vec<String>) {
|
||||
let dir = TempDir::new().expect("a temp dir");
|
||||
let paths = vec![dir.path().to_string_lossy().into_owned()];
|
||||
(dir, paths)
|
||||
}
|
||||
|
||||
fn kinds<'a>(warnings: &'a [margin_docs_lib::dto::PdfWarning], kind: &str) -> Vec<&'a str> {
|
||||
warnings
|
||||
.iter()
|
||||
.filter(|w| w.kind == kind)
|
||||
.map(|w| w.message.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_with_an_image_and_a_formula_compiles() {
|
||||
let (dir, roots) = root();
|
||||
let photo = dir.path().join("photo.png");
|
||||
std::fs::write(&photo, png_bytes()).unwrap();
|
||||
|
||||
let source = format!(
|
||||
"{PREAMBLE}
|
||||
= Fixture
|
||||
|
||||
Inline #mi(\"a^2 + b^2 = c^2\") in a sentence.
|
||||
|
||||
#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\")
|
||||
|
||||
#image(\"{}\")
|
||||
|
||||
Diagram: #image(\"diagram.svg\")
|
||||
",
|
||||
photo.display()
|
||||
);
|
||||
|
||||
let images = vec![
|
||||
ImageInput {
|
||||
path: photo.to_string_lossy().into_owned(),
|
||||
data: None,
|
||||
},
|
||||
// How a mermaid diagram arrives: rendered to SVG in the webview, with no file behind it.
|
||||
ImageInput {
|
||||
path: "diagram.svg".to_string(),
|
||||
data: Some(STANDARD.encode(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#333"/></svg>"##,
|
||||
)),
|
||||
},
|
||||
];
|
||||
|
||||
let (bytes, warnings) = compile(source, &images, &roots).expect("the fixture compiles");
|
||||
|
||||
assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF");
|
||||
assert!(
|
||||
kinds(&warnings, "image").is_empty(),
|
||||
"no image was worked around: {:?}",
|
||||
kinds(&warnings, "image")
|
||||
);
|
||||
assert!(
|
||||
kinds(&warnings, "math").is_empty(),
|
||||
"no formula was worked around: {:?}",
|
||||
kinds(&warnings, "math")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_typesets_through_mitex_rather_than_falling_back_to_source() {
|
||||
let (_dir, roots) = root();
|
||||
|
||||
// Only the wasm plugin can turn `\frac{a}{b}` into Typst's own `frac(a, b )`, so asserting on
|
||||
// what came back out of it proves the whole path resolved: the import, the relative imports
|
||||
// underneath it, the specs and the plugin. An `assert` inside the document makes a wrong answer
|
||||
// a compile error rather than a difference in bytes nobody would notice.
|
||||
let source = format!(
|
||||
"{PREAMBLE}#import \"/mitex/lib.typ\": mitex-convert
|
||||
#assert.eq(mitex-convert(\"\\\\frac{{a}}{{b}}\"), \"frac(a ,b )\")
|
||||
#mi(\"\\\\alpha + \\\\beta\")
|
||||
"
|
||||
);
|
||||
|
||||
let (bytes, warnings) = compile(source, &[], &roots).expect("mitex loads and converts");
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
assert!(
|
||||
kinds(&warnings, "math").is_empty(),
|
||||
"the formula typeset without a workaround: {:?}",
|
||||
kinds(&warnings, "math")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mitex_files_are_the_only_thing_the_compiler_can_import() {
|
||||
let (_dir, roots) = root();
|
||||
|
||||
let source = format!("{PREAMBLE}#import \"/etc/passwd\": *\n");
|
||||
let error = compile(source, &[], &roots).expect_err("nothing outside the vendored files loads");
|
||||
assert!(
|
||||
error.contains("file not found"),
|
||||
"the compiler has no filesystem: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_outside_every_open_folder_is_never_read() {
|
||||
let (dir, roots) = root();
|
||||
// Somewhere the user did not open, which is what `` resolves to.
|
||||
let elsewhere = TempDir::new().expect("a temp dir");
|
||||
let secret = elsewhere.path().join("id_rsa");
|
||||
std::fs::write(&secret, "PRIVATE KEY").unwrap();
|
||||
let _ = dir;
|
||||
|
||||
let source = format!(
|
||||
"{PREAMBLE}#image(\"{}\")\n",
|
||||
secret.display()
|
||||
);
|
||||
let images = vec![ImageInput {
|
||||
path: secret.to_string_lossy().into_owned(),
|
||||
data: None,
|
||||
}];
|
||||
|
||||
let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens");
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
|
||||
let refused = kinds(&warnings, "image");
|
||||
assert_eq!(refused.len(), 1, "one image was worked around: {refused:?}");
|
||||
assert!(
|
||||
refused[0].contains("outside every open folder"),
|
||||
"the root guard is what refused it: {}",
|
||||
refused[0]
|
||||
);
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&bytes).contains("PRIVATE KEY"),
|
||||
"nothing from the file reached the page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_broken_image_does_not_cost_the_whole_export() {
|
||||
let (dir, roots) = root();
|
||||
let missing = dir.path().join("gone.jpg");
|
||||
|
||||
let source = format!(
|
||||
"{PREAMBLE}#image(\"{0}\")\n\n#image(\"{0}\")\n",
|
||||
missing.display()
|
||||
);
|
||||
let images = vec![
|
||||
ImageInput {
|
||||
path: missing.to_string_lossy().into_owned(),
|
||||
data: None,
|
||||
},
|
||||
ImageInput {
|
||||
path: missing.to_string_lossy().into_owned(),
|
||||
data: None,
|
||||
},
|
||||
];
|
||||
|
||||
let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens");
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
|
||||
// Two broken links to one file are one problem with a number on it, not two toasts.
|
||||
let counted: Vec<u32> = warnings
|
||||
.iter()
|
||||
.filter(|w| w.kind == "image")
|
||||
.map(|w| w.count)
|
||||
.collect();
|
||||
assert_eq!(counted, vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_nothing_can_typeset_does_not_cost_the_export() {
|
||||
let (_dir, roots) = root();
|
||||
|
||||
// Unbalanced braces, which is what somebody halfway through typing a formula has. mitex cannot
|
||||
// parse it and says so by stopping the compile, and the whole page of notes would go with it.
|
||||
let source = format!("{PREAMBLE}#mi(\"\\\\frac{{\")\n");
|
||||
|
||||
let (bytes, warnings) = compile(source, &[], &roots).expect("the export still happens");
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
assert_eq!(
|
||||
kinds(&warnings, "math"),
|
||||
vec!["a formula could not be typeset, so every formula is shown as the source it was written in"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_document_says_nothing_at_all() {
|
||||
let (_dir, roots) = root();
|
||||
|
||||
// Every construct that has ever made this module warn about its own furniture rather than about
|
||||
// the document: the bundled variable fonts, the font families the preamble names, and mitex's
|
||||
// own deprecations. A user who wrote none of that should see no toast.
|
||||
let source = format!(
|
||||
"{PREAMBLE}
|
||||
= Heading
|
||||
|
||||
A paragraph with `inline code` and #mi(\"a^2 + b^2\") in it.
|
||||
|
||||
```rust
|
||||
fn main() {{}}
|
||||
```
|
||||
"
|
||||
);
|
||||
|
||||
let (bytes, warnings) = compile(source, &[], &roots).expect("the fixture compiles");
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
assert!(warnings.is_empty(), "nothing to say: {warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_link_is_worked_around_in_every_format_the_editor_writes() {
|
||||
let (dir, roots) = root();
|
||||
|
||||
// Typst decides how to decode an image from the extension and not from the bytes, so the
|
||||
// stand-in for a file that could not be read has to be a real image of the format the link
|
||||
// claimed. A png in place of a jpg is a decode error, which is the failure being avoided.
|
||||
for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg", "heic"] {
|
||||
let missing = dir.path().join(format!("gone.{extension}"));
|
||||
let source = format!("{PREAMBLE}#image(\"{}\")\n", missing.display());
|
||||
let images = vec![ImageInput {
|
||||
path: missing.to_string_lossy().into_owned(),
|
||||
data: None,
|
||||
}];
|
||||
|
||||
let (bytes, warnings) = compile(source, &images, &roots)
|
||||
.unwrap_or_else(|e| panic!("a missing .{extension} still exports: {e}"));
|
||||
assert_eq!(&bytes[..4], b"%PDF");
|
||||
assert_eq!(kinds(&warnings, "image").len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// mitex's `\hspace`, `\vspace` and `\raisebox` handlers used to hand the text between the braces
|
||||
/// to Typst's `eval`, which ran it as code. The text is whatever the author typed, so a markdown
|
||||
/// file was a program, and the compiler runs in this process rather than beside it: the payload
|
||||
/// vendor/mitex/PATCHES.md names took nine gigabytes and the app with it.
|
||||
///
|
||||
/// That payload is deliberately not the fixture here. A test that brings down the machine when it
|
||||
/// fails is not a test anybody can run, and the property underneath it is smaller and exact: a
|
||||
/// length literal is evaluated and nothing else is. `2cm*2` is the whole difference, because it is
|
||||
/// arithmetic rather than a literal, and under `eval` it was four centimetres.
|
||||
///
|
||||
/// Proved by what lands on the page rather than by reading the guard, which needs one control: the
|
||||
/// same source twice is the same bytes, so two documents differing only in a spacing command and
|
||||
/// differing in bytes is that spacing command doing something.
|
||||
#[test]
|
||||
fn a_spacing_command_takes_a_length_and_never_an_expression() {
|
||||
let (_dir, roots) = root();
|
||||
let page = |length: &str| {
|
||||
let source = format!("{PREAMBLE}x#mi(\"a\\\\hspace{{{length}}}b\")x\n");
|
||||
compile(source, &[], &roots).expect("a spacing command typesets").0
|
||||
};
|
||||
|
||||
assert_eq!(page("0pt"), page("0pt"), "the compiler is deterministic");
|
||||
assert_ne!(page("0pt"), page("4cm"), "4cm moved nothing on the page");
|
||||
assert_eq!(page("0pt"), page("2cm*2"), "an expression reached eval");
|
||||
}
|
||||
|
||||
/// Every weight src/export/typst.ts asks for has to be a face of its own, or the hierarchy the
|
||||
/// author sees on screen is not in the PDF.
|
||||
///
|
||||
/// This is what the static instances in src-tauri/fonts are for. Typst does not lay out a variable
|
||||
/// axis: it takes the default instance, and every weight comes out at 400. There is no way to read a
|
||||
/// weight back out of a PDF here, so the assertion is that the same word at two weights is two
|
||||
/// different documents, which stops being true the moment two weights resolve to one face.
|
||||
#[test]
|
||||
fn every_weight_the_converter_asks_for_has_a_face_of_its_own() {
|
||||
let (_dir, roots) = root();
|
||||
let page = |markup: &str| {
|
||||
let source = format!("{PREAMBLE}{markup}\n");
|
||||
compile(source, &[], &roots).expect("a weight typesets").0
|
||||
};
|
||||
|
||||
let cuts = [
|
||||
"#text(weight: 400)[Margin]",
|
||||
"#text(weight: 600)[Margin]",
|
||||
"#text(weight: 700)[Margin]",
|
||||
"#text(weight: 400, style: \"italic\")[Margin]",
|
||||
"#text(weight: 700, style: \"italic\")[Margin]",
|
||||
"#text(font: \"Hanken Grotesk\", weight: 400)[Margin]",
|
||||
"#text(font: \"Hanken Grotesk\", weight: 700)[Margin]",
|
||||
];
|
||||
|
||||
for (i, one) in cuts.iter().enumerate() {
|
||||
for two in &cuts[i + 1..] {
|
||||
assert_ne!(page(one), page(two), "{one} and {two} are the same face");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user