feature fixes

This commit is contained in:
pj committed 2026-08-28 21:47:48 +05:30
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(&notes, 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;
}
+126
View File
@@ -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))
);
}
+316
View File
@@ -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 `![](../../../.ssh/id_rsa)` 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");
}
}
}