oxedyne/fe2o3/fe2o3_austenite/tests/wasm_api.rs
11.3 KiB, 5 runs
created by r1870400018:59839, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The native half of the wasm compile surface's reporting: page counts, positioned diagnostics, strict |
| 2 | //! refusals, font families and engine identity, driven through the same `compile` functions the wasm |
| 3 | //! `DaimondTypst` calls with the source map installed as it installs one. External facts check each: the |
| 4 | //! PDF's page count from `pdfinfo`, the embedded family names from `fc-scan`, the commit from `git`. |
| 5 | |
| 6 | use oxedyne_fe2o3_austenite::compile::{ |
| 7 | self, |
| 8 | Diagnostic, |
| 9 | Report, |
| 10 | }; |
| 11 | use oxedyne_fe2o3_austenite::fonts; |
| 12 | use oxedyne_fe2o3_austenite::vfs; |
| 13 | |
| 14 | use oxedyne_fe2o3_core::prelude::*; |
| 15 | |
| 16 | use std::collections::HashMap; |
| 17 | use std::path::{ |
| 18 | Path, |
| 19 | PathBuf, |
| 20 | }; |
| 21 | use std::process::Command; |
| 22 | use std::sync::{ |
| 23 | Arc, |
| 24 | Mutex, |
| 25 | MutexGuard, |
| 26 | }; |
| 27 | |
| 28 | // The source map is a process global, so the tests that install one take turns. |
| 29 | static VFS_TURN: Mutex<()> = Mutex::new(()); |
| 30 | |
| 31 | /// Takes this test's turn at the source map; a turn a failed test poisoned is still a turn. |
| 32 | fn turn() -> MutexGuard<'static, ()> { |
| 33 | VFS_TURN.lock().unwrap_or_else(|p| p.into_inner()) |
| 34 | } |
| 35 | |
| 36 | const MAIN: &str = "/proj/main.typ"; |
| 37 | |
| 38 | /// What a compile of the installed project returned: its report and PDF, or the placed hard error. |
| 39 | enum Compiled { |
| 40 | Done(Report, Vec<u8>), |
| 41 | Failed(Diagnostic), |
| 42 | } |
| 43 | |
| 44 | /// Installs `files`, compiles `MAIN` to PDF as the wasm `compileProject` does, and clears the map. |
| 45 | fn compile_pdf(files: &[(&str, &[u8])]) -> Outcome<Compiled> { |
| 46 | let mut map: HashMap<PathBuf, Vec<u8>> = HashMap::new(); |
| 47 | for (p, b) in files { |
| 48 | map.insert(PathBuf::from(p), b.to_vec()); |
| 49 | } |
| 50 | let sources: Vec<PathBuf> = map.keys().cloned().collect(); |
| 51 | res!(vfs::install(map)); |
| 52 | let main = PathBuf::from(MAIN); |
| 53 | let fonts = Arc::new(res!(fonts::libertinus())); |
| 54 | let run = || -> Outcome<(Report, Vec<u8>)> { |
| 55 | let (assembled, refusals, skip) = res!(compile::assemble(&main, || Ok(fonts.clone()))); |
| 56 | let empty = assembled.blocks.is_empty(); |
| 57 | let mut rendered = res!(compile::author_and_run(assembled)); |
| 58 | let report = Report::new(rendered.out.pages.len(), &refusals, skip, empty); |
| 59 | let pdf = res!(compile::emit_pdf(&mut rendered.out, &rendered.heads)); |
| 60 | Ok((report, pdf)) |
| 61 | }; |
| 62 | let out = match run() { |
| 63 | Ok((r, pdf)) => Compiled::Done(r, pdf), |
| 64 | Err(e) => Compiled::Failed(compile::locate_error(&e, &main, &sources)), |
| 65 | }; |
| 66 | res!(vfs::clear()); |
| 67 | Ok(out) |
| 68 | } |
| 69 | |
| 70 | fn done(c: Compiled) -> Outcome<(Report, Vec<u8>)> { |
| 71 | match c { |
| 72 | Compiled::Done(r, pdf) => Ok((r, pdf)), |
| 73 | Compiled::Failed(d) => Err(err!("Expected a compile, got the error {}.", d; Test)), |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | fn failed(c: Compiled) -> Outcome<Diagnostic> { |
| 78 | match c { |
| 79 | Compiled::Failed(d) => Ok(d), |
| 80 | Compiled::Done(r, _) => Err(err!("Expected an error, got {} page(s).", r.pages; Test)), |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// The `Pages:` line `pdfinfo` reads from a PDF written to the scratch target directory. |
| 85 | fn pdfinfo_pages(pdf: &[u8], name: &str) -> Outcome<usize> { |
| 86 | let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")); |
| 87 | let path = dir.join(fmt!("{}.pdf", name)); |
| 88 | res!(std::fs::write(&path, pdf)); |
| 89 | let out = res!(Command::new("pdfinfo").arg(&path).output()); |
| 90 | let text = res!(String::from_utf8(out.stdout)); |
| 91 | for line in text.lines() { |
| 92 | if let Some(rest) = line.strip_prefix("Pages:") { |
| 93 | return Ok(res!(rest.trim().parse::<usize>())); |
| 94 | } |
| 95 | } |
| 96 | Err(err!("pdfinfo printed no Pages line for {:?}: {}", path, text; Test)) |
| 97 | } |
| 98 | |
| 99 | fn prose(paras: usize) -> String { |
| 100 | let mut s = String::from("= Report\n\n"); |
| 101 | for i in 0..paras { |
| 102 | s.push_str(&fmt!("Paragraph {} sets enough ordinary words to fill a few lines of the measure, so \ |
| 103 | that forty of them spill well past a single A4 page of body text.\n\n", i)); |
| 104 | } |
| 105 | s |
| 106 | } |
| 107 | |
| 108 | #[test] |
| 109 | fn reported_pages_match_the_pdf_and_a_clean_compile_passes_strict() -> Outcome<()> { |
| 110 | let _turn = turn(); |
| 111 | let src = prose(40); |
| 112 | let (report, pdf) = res!(done(res!(compile_pdf(&[(MAIN, src.as_bytes())])))); |
| 113 | let external = res!(pdfinfo_pages(&pdf, "wasm_api_pages")); |
| 114 | assert!(report.pages > 1, "the fixture must paginate, found {} page(s)", report.pages); |
| 115 | assert_eq!(report.pages, external, "the reported page count must be the PDF's own"); |
| 116 | assert!(report.diagnostics.is_empty(), "a clean compile has no diagnostics: {:?}", report.diagnostics); |
| 117 | assert_eq!(report.skipped, None); |
| 118 | assert_eq!(report.strict_failure(Path::new(MAIN)), None, "a clean compile passes strict"); |
| 119 | Ok(()) |
| 120 | } |
| 121 | |
| 122 | #[test] |
| 123 | fn a_skipped_construct_is_positioned_and_refused_under_strict() -> Outcome<()> { |
| 124 | let _turn = turn(); |
| 125 | // `#columns` opens line 5. |
| 126 | let src = "= H\n\nBody.\n\n#columns(2)[a]\n\nMore body.\n"; |
| 127 | let (report, pdf) = res!(done(res!(compile_pdf(&[(MAIN, src.as_bytes())])))); |
| 128 | assert!(pdf.starts_with(b"%PDF-"), "a non-strict compile still produces its PDF"); |
| 129 | assert_eq!(report.diagnostics.len(), 1, "one refused site: {:?}", report.diagnostics); |
| 130 | let d = &report.diagnostics[0]; |
| 131 | assert_eq!((d.file.as_str(), d.line, d.col), (MAIN, 5, 1), "the site's real position: {}", d); |
| 132 | assert!(d.message.contains("#columns"), "the message names the construct: {}", d); |
| 133 | assert_eq!(report.skipped.as_deref(), Some("skipped: #columns ×1")); |
| 134 | |
| 135 | let head = match report.strict_failure(Path::new(MAIN)) { |
| 136 | Some(h) => h, |
| 137 | None => return Err(err!("strict must refuse a compile that skipped a construct"; Test)), |
| 138 | }; |
| 139 | assert_eq!((head.line, head.col), (5, 1), "strict reports at the first site: {}", head); |
| 140 | assert!(fmt!("{}", head).starts_with("/proj/main.typ:5:1: strict:"), "the error line: {}", head); |
| 141 | Ok(()) |
| 142 | } |
| 143 | |
| 144 | #[test] |
| 145 | fn a_failed_import_is_a_diagnostic_and_a_strict_error() -> Outcome<()> { |
| 146 | let _turn = turn(); |
| 147 | let src = "= H\n\n#import \"template.typ\": *\n\nBody.\n"; |
| 148 | let (report, _) = res!(done(res!(compile_pdf(&[(MAIN, src.as_bytes())])))); |
| 149 | let first = match report.diagnostics.first() { |
| 150 | Some(d) => d.clone(), |
| 151 | None => return Err(err!("an unresolved #import must be reported"; Test)), |
| 152 | }; |
| 153 | assert_eq!((first.line, first.col), (3, 1), "{}", first); |
| 154 | assert!(first.message.contains("#import"), "{}", first); |
| 155 | assert!(report.strict_failure(Path::new(MAIN)).is_some()); |
| 156 | Ok(()) |
| 157 | } |
| 158 | |
| 159 | #[test] |
| 160 | fn strict_refuses_an_empty_source_and_zero_pages() -> Outcome<()> { |
| 161 | let _turn = turn(); |
| 162 | let (report, _) = res!(done(res!(compile_pdf(&[(MAIN, b"")])))); |
| 163 | assert!(report.empty, "an empty main reads no content block"); |
| 164 | assert!(report.diagnostics.is_empty(), "and refuses nothing, so only the emptiness catches it"); |
| 165 | let head = match report.strict_failure(Path::new(MAIN)) { |
| 166 | Some(h) => h, |
| 167 | None => return Err(err!("strict must refuse a source that sets nothing"; Test)), |
| 168 | }; |
| 169 | assert_eq!(fmt!("{}", head), "/proj/main.typ:1:1: strict: the source sets no content."); |
| 170 | |
| 171 | let none = Report { pages: 0, diagnostics: Vec::new(), skipped: None, empty: false }; |
| 172 | let head = match none.strict_failure(Path::new(MAIN)) { |
| 173 | Some(h) => h, |
| 174 | None => return Err(err!("strict must refuse zero pages"; Test)), |
| 175 | }; |
| 176 | assert!(head.message.contains("no pages"), "{}", head); |
| 177 | Ok(()) |
| 178 | } |
| 179 | |
| 180 | #[test] |
| 181 | fn a_missing_include_is_placed_at_the_line_that_cites_it() -> Outcome<()> { |
| 182 | let _turn = turn(); |
| 183 | // Cited from the root. |
| 184 | let root = "= H\n\nBody.\n#include \"ch1.typ\"\n"; |
| 185 | let d = res!(failed(res!(compile_pdf(&[(MAIN, root.as_bytes())])))); |
| 186 | assert_eq!((d.file.as_str(), d.line, d.col), (MAIN, 4, 10), "{}", d); |
| 187 | assert!(d.message.contains("ch1.typ") && !d.message.contains(".rs:"), "plain words: {}", d); |
| 188 | |
| 189 | // Cited from a chapter one directory down, resolved against the chapter's own directory. |
| 190 | let root = "= H\n\n#include \"parts/a.typ\"\n"; |
| 191 | let chap = "== A\n\nText.\n\n#include \"../gone.typ\"\n"; |
| 192 | let d = res!(failed(res!(compile_pdf(&[(MAIN, root.as_bytes()), ("/proj/parts/a.typ", chap.as_bytes())])))); |
| 193 | assert_eq!((d.file.as_str(), d.line, d.col), ("/proj/parts/a.typ", 5, 10), "{}", d); |
| 194 | Ok(()) |
| 195 | } |
| 196 | |
| 197 | #[test] |
| 198 | fn an_error_citing_no_source_path_reports_zero_zero() -> Outcome<()> { |
| 199 | let e = err!("Something failed with no path."; Test); |
| 200 | let d = compile::locate_error(&e, Path::new(MAIN), &[]); |
| 201 | assert_eq!(fmt!("{}", d), "/proj/main.typ:0:0: Something failed with no path."); |
| 202 | Ok(()) |
| 203 | } |
| 204 | |
| 205 | /// The family name `fc-scan` reads from a font file's own name table. |
| 206 | fn fc_family(path: &Path) -> Outcome<String> { |
| 207 | let out = res!(Command::new("fc-scan").arg("--format").arg("%{family[0]}").arg(path).output()); |
| 208 | Ok(res!(String::from_utf8(out.stdout)).trim().to_string()) |
| 209 | } |
| 210 | |
| 211 | #[test] |
| 212 | fn embedded_families_are_the_names_in_the_font_files() -> Outcome<()> { |
| 213 | let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts"); |
| 214 | let mut read = Vec::new(); |
| 215 | for f in ["LibertinusSerif-Regular.otf", "LibertinusMono-Regular.otf", "NewCMMath-Regular.otf"] { |
| 216 | read.push(res!(fc_family(&dir.join(f)))); |
| 217 | } |
| 218 | let mut listed: Vec<String> = compile::EMBEDDED_FAMILIES.iter().map(|s| s.to_string()).collect(); |
| 219 | read.sort(); |
| 220 | listed.sort(); |
| 221 | // Same-family rather than a literal match: the New Computer Modern files declare `NewComputerModern |
| 222 | // Math` in their own name table, where the embedded list and every document write it `New Computer |
| 223 | // Modern Math` (see `fonts::same_family`'s own doc comment for why). |
| 224 | assert_eq!(listed.len(), read.len(), "the embedded list must name exactly the files' own families"); |
| 225 | for (l, r) in listed.iter().zip(read.iter()) { |
| 226 | assert!(fonts::same_family(l, r), "{:?} is not the family {:?} declares", l, r); |
| 227 | } |
| 228 | Ok(()) |
| 229 | } |
| 230 | |
| 231 | #[test] |
| 232 | fn injected_families_are_listed_only_when_the_resolver_loads_them() -> Outcome<()> { |
| 233 | let _turn = turn(); |
| 234 | let face = res!(std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LibertinusSerif-Regular.otf"))); |
| 235 | let main = PathBuf::from(MAIN); |
| 236 | let given = [ |
| 237 | PathBuf::from("/fonts/Testface-Regular.otf"), // a usable face under an arbitrary path |
| 238 | PathBuf::from("Otherface-Bold.ttf"), // a bare basename, bold only |
| 239 | PathBuf::from("/fonts/Broken-Regular.ttf"), // will not parse |
| 240 | PathBuf::from("/fonts/nameless.otf"), // no <Family>-<Variant> name |
| 241 | ]; |
| 242 | let mut map: HashMap<PathBuf, Vec<u8>> = HashMap::new(); |
| 243 | map.insert(main.clone(), b"= H\n".to_vec()); |
| 244 | for g in &given { |
| 245 | let bytes = if g.to_string_lossy().contains("Broken") { b"not a font".to_vec() } else { face.clone() }; |
| 246 | // Installed as the wasm surface installs a font: at the given path and at the resolver's path. |
| 247 | if let Some(routed) = oxedyne_fe2o3_austenite::book::project_font_path(&main, g) { |
| 248 | map.insert(routed, bytes.clone()); |
| 249 | } |
| 250 | map.insert(g.clone(), bytes); |
| 251 | } |
| 252 | let bare = compile::font_families(&main, &[]); |
| 253 | res!(vfs::install(map)); |
| 254 | let families = compile::font_families(&main, &given); |
| 255 | res!(vfs::clear()); |
| 256 | |
| 257 | let mut want: Vec<String> = compile::EMBEDDED_FAMILIES.iter().map(|s| s.to_string()).collect(); |
| 258 | want.sort(); |
| 259 | assert_eq!(bare, want, "with nothing injected only the embedded families are listed"); |
| 260 | want.push("Otherface".to_string()); |
| 261 | want.push("Testface".to_string()); |
| 262 | want.sort(); |
| 263 | assert_eq!(families, want); |
| 264 | Ok(()) |
| 265 | } |
| 266 | |
| 267 | #[test] |
| 268 | fn engine_identity_is_the_crate_version_and_the_built_commit() -> Outcome<()> { |
| 269 | let manifest = res!(std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"))); |
| 270 | let version = manifest.lines() |
| 271 | .find_map(|l| l.strip_prefix("version = \"").and_then(|r| r.strip_suffix('"'))); |
| 272 | assert_eq!(Some(compile::engine_version()), version); |
| 273 | |
| 274 | let out = res!(Command::new("git").args(["rev-parse", "--short=12", "HEAD"]) |
| 275 | .current_dir(env!("CARGO_MANIFEST_DIR")).output()); |
| 276 | let head = res!(String::from_utf8(out.stdout)).trim().to_string(); |
| 277 | let hash = compile::engine_git_hash(); |
| 278 | assert_eq!(head.len(), 12, "git must name the commit: {:?}", head); |
| 279 | assert!(hash == head || hash == fmt!("{}-dirty", head), "built from {:?}, HEAD is {:?}", hash, head); |
| 280 | Ok(()) |
| 281 | } |