Refine test infrastructure

- Tests diagnostics
- More and better separated image tests
This commit is contained in:
Laurenz 2020-12-10 22:44:35 +01:00
parent fdc1b378a3
commit 1cbd5f3051
25 changed files with 240 additions and 126 deletions

3
.gitignore vendored
View File

@ -3,6 +3,5 @@
**/*.rs.bk **/*.rs.bk
Cargo.lock Cargo.lock
bench/target bench/target
tests/png tests/out
tests/pdf
_things _things

View File

@ -8,26 +8,22 @@ edition = "2018"
members = ["bench"] members = ["bench"]
[features] [features]
default = ["fs", "anyhow"] default = ["cli", "fs"]
cli = ["fs", "anyhow"]
fs = ["fontdock/fs"] fs = ["fontdock/fs"]
[dependencies] [dependencies]
fontdock = { path = "../fontdock", default-features = false }
pdf-writer = { path = "../pdf-writer" }
deflate = { version = "0.8.6" } deflate = { version = "0.8.6" }
fontdock = { path = "../fontdock", default-features = false }
image = { version = "0.23", default-features = false, features = ["jpeg", "png"] } image = { version = "0.23", default-features = false, features = ["jpeg", "png"] }
itoa = "0.4" itoa = "0.4"
pdf-writer = { path = "../pdf-writer" }
ttf-parser = "0.8.2" ttf-parser = "0.8.2"
unicode-xid = "0.2" unicode-xid = "0.2"
anyhow = { version = "1", optional = true }
# feature = "serde"
serde = { version = "1", features = ["derive"], optional = true } serde = { version = "1", features = ["derive"], optional = true }
# for the CLI
anyhow = { version = "1", optional = true }
[dev-dependencies] [dev-dependencies]
memmap = "0.7"
tiny-skia = "0.2" tiny-skia = "0.2"
[profile.dev] [profile.dev]
@ -38,7 +34,7 @@ lto = true
[[bin]] [[bin]]
name = "typst" name = "typst"
required-features = ["fs", "anyhow"] required-features = ["cli"]
[[test]] [[test]]
name = "typeset" name = "typeset"

View File

@ -13,7 +13,7 @@ use typst::parse::parse;
use typst::typeset; use typst::typeset;
const FONT_DIR: &str = "../fonts"; const FONT_DIR: &str = "../fonts";
const COMA: &str = include_str!("../../tests/typ/coma.typ"); const COMA: &str = include_str!("../../tests/typ/example-coma.typ");
fn benchmarks(c: &mut Criterion) { fn benchmarks(c: &mut Criterion) {
macro_rules! bench { macro_rules! bench {

View File

@ -17,6 +17,11 @@ impl Point {
pub fn new(x: Length, y: Length) -> Self { pub fn new(x: Length, y: Length) -> Self {
Self { x, y } Self { x, y }
} }
/// Create an instance with two equal components.
pub fn uniform(value: Length) -> Self {
Self { x: value, y: value }
}
} }
impl Get<SpecAxis> for Point { impl Get<SpecAxis> for Point {

View File

@ -21,6 +21,11 @@ impl Size {
Self { width, height } Self { width, height }
} }
/// Create an instance with two equal components.
pub fn uniform(value: Length) -> Self {
Self { width: value, height: value }
}
/// Whether the other size fits into this one (smaller width and height). /// Whether the other size fits into this one (smaller width and height).
pub fn fits(self, other: Self) -> bool { pub fn fits(self, other: Self) -> bool {
self.width >= other.width && self.height >= other.height self.width >= other.width && self.height >= other.height

View File

@ -59,10 +59,10 @@ fn main() -> anyhow::Result<()> {
let map = LineMap::new(&src); let map = LineMap::new(&src);
for diag in diags { for diag in diags {
let span = diag.span; let span = diag.span;
let start = map.location(span.start); let start = map.location(span.start).unwrap();
let end = map.location(span.end); let end = map.location(span.end).unwrap();
println!( println!(
" {}: {}:{}-{}: {}", "{}: {}:{}-{}: {}",
diag.v.level, diag.v.level,
src_path.display(), src_path.display(),
start, start,

View File

@ -1,7 +1,7 @@
//! Conversion of byte positions to line/column locations. //! Conversion of byte positions to line/column locations.
use super::Scanner; use super::Scanner;
use crate::syntax::{Location, Pos}; use crate::syntax::{Location, Offset, Pos};
/// Enables conversion of byte position to locations. /// Enables conversion of byte position to locations.
pub struct LineMap<'s> { pub struct LineMap<'s> {
@ -25,23 +25,48 @@ impl<'s> LineMap<'s> {
} }
/// Convert a byte position to a location. /// Convert a byte position to a location.
/// pub fn location(&self, pos: Pos) -> Option<Location> {
/// # Panics // Find the line which contains the position.
/// This panics if the position is out of bounds.
pub fn location(&self, pos: Pos) -> Location {
let line_index = match self.line_starts.binary_search(&pos) { let line_index = match self.line_starts.binary_search(&pos) {
Ok(i) => i, Ok(i) => i,
Err(i) => i - 1, Err(i) => i - 1,
}; };
let line_start = self.line_starts[line_index]; let start = self.line_starts.get(line_index)?;
let head = &self.src[line_start.to_usize() .. pos.to_usize()]; let head = self.src.get(start.to_usize() .. pos.to_usize())?;
let column_index = head.chars().count(); let column_index = head.chars().count();
Location { Some(Location {
line: 1 + line_index as u32, line: 1 + line_index as u32,
column: 1 + column_index as u32, column: 1 + column_index as u32,
} })
}
/// Convert a location to a byte position.
pub fn pos(&self, location: Location) -> Option<Pos> {
// Determine the boundaries of the line.
let line_idx = location.line.checked_sub(1)? as usize;
let line_start = self.line_starts.get(line_idx)?;
let line_end = self
.line_starts
.get(location.line as usize)
.map_or(self.src.len(), |pos| pos.to_usize());
let line = self.src.get(line_start.to_usize() .. line_end)?;
// Find the index in the line. For the first column, the index is always zero. For
// other columns, we have to look at which byte the char directly before the
// column in question ends. We can't do `nth(column_idx)` directly since the
// column may be behind the last char.
let column_idx = location.column.checked_sub(1)? as usize;
let line_offset = if let Some(prev_idx) = column_idx.checked_sub(1) {
let (idx, prev) = line.char_indices().nth(prev_idx)?;
idx + prev.len_utf8()
} else {
0
};
Some(line_start.offset(Pos(line_offset as u32)))
} }
} }
@ -71,18 +96,26 @@ mod tests {
#[test] #[test]
fn test_line_map_location() { fn test_line_map_location() {
let map = LineMap::new(TEST); let map = LineMap::new(TEST);
assert_eq!(map.location(Pos(0)), Location::new(1, 1)); assert_eq!(map.location(Pos(0)), Some(Location::new(1, 1)));
assert_eq!(map.location(Pos(2)), Location::new(1, 2)); assert_eq!(map.location(Pos(2)), Some(Location::new(1, 2)));
assert_eq!(map.location(Pos(6)), Location::new(1, 6)); assert_eq!(map.location(Pos(6)), Some(Location::new(1, 6)));
assert_eq!(map.location(Pos(7)), Location::new(2, 1)); assert_eq!(map.location(Pos(7)), Some(Location::new(2, 1)));
assert_eq!(map.location(Pos(8)), Location::new(2, 2)); assert_eq!(map.location(Pos(8)), Some(Location::new(2, 2)));
assert_eq!(map.location(Pos(12)), Location::new(2, 3)); assert_eq!(map.location(Pos(12)), Some(Location::new(2, 3)));
assert_eq!(map.location(Pos(21)), Location::new(4, 4)); assert_eq!(map.location(Pos(21)), Some(Location::new(4, 4)));
assert_eq!(map.location(Pos(22)), None);
} }
#[test] #[test]
#[should_panic] fn test_line_map_pos() {
fn test_line_map_panics_out_of_bounds() { fn assert_round_trip(map: &LineMap, pos: Pos) {
LineMap::new(TEST).location(Pos(22)); assert_eq!(map.location(pos).and_then(|loc| map.pos(loc)), Some(pos));
}
let map = LineMap::new(TEST);
assert_round_trip(&map, Pos(0));
assert_round_trip(&map, Pos(7));
assert_round_trip(&map, Pos(12));
assert_round_trip(&map, Pos(21));
} }
} }

View File

@ -56,24 +56,31 @@ use Unit::*;
fn Id(ident: &str) -> Expr { fn Id(ident: &str) -> Expr {
Expr::Lit(Lit::Ident(Ident(ident.to_string()))) Expr::Lit(Lit::Ident(Ident(ident.to_string())))
} }
fn Bool(b: bool) -> Expr { fn Bool(b: bool) -> Expr {
Expr::Lit(Lit::Bool(b)) Expr::Lit(Lit::Bool(b))
} }
fn Int(int: i64) -> Expr { fn Int(int: i64) -> Expr {
Expr::Lit(Lit::Int(int)) Expr::Lit(Lit::Int(int))
} }
fn Float(float: f64) -> Expr { fn Float(float: f64) -> Expr {
Expr::Lit(Lit::Float(float)) Expr::Lit(Lit::Float(float))
} }
fn Percent(percent: f64) -> Expr { fn Percent(percent: f64) -> Expr {
Expr::Lit(Lit::Percent(percent)) Expr::Lit(Lit::Percent(percent))
} }
fn Length(val: f64, unit: Unit) -> Expr { fn Length(val: f64, unit: Unit) -> Expr {
Expr::Lit(Lit::Length(val, unit)) Expr::Lit(Lit::Length(val, unit))
} }
fn Color(color: RgbaColor) -> Expr { fn Color(color: RgbaColor) -> Expr {
Expr::Lit(Lit::Color(color)) Expr::Lit(Lit::Color(color))
} }
fn Str(string: &str) -> Expr { fn Str(string: &str) -> Expr {
Expr::Lit(Lit::Str(string.to_string())) Expr::Lit(Lit::Str(string.to_string()))
} }
@ -98,6 +105,7 @@ fn Unary(op: impl Into<Spanned<UnOp>>, expr: impl Into<Spanned<Expr>>) -> Expr {
expr: expr.into().map(Box::new), expr: expr.into().map(Box::new),
}) })
} }
fn Binary( fn Binary(
op: impl Into<Spanned<BinOp>>, op: impl Into<Spanned<BinOp>>,
lhs: impl Into<Spanned<Expr>>, lhs: impl Into<Spanned<Expr>>,

View File

@ -1,8 +1,7 @@
# Tests # Tests
- `typ`: Input files - `typ`: Input files
- `pdf`: PDF files produced by tests - `ref`: Reference images which the output is compared with to determine
- `png`: PNG files produced by tests whether a test passed or failed
- `cmp`: Reference images which the PNGs are compared to byte-wise to determine
whether the test passed or failed
- `res`: Resource files used by tests - `res`: Resource files used by tests
- `out`: PNG and PDF files produced by tests

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

BIN
tests/ref/empty.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

BIN
tests/ref/example-coma.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
tests/ref/image-error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

BIN
tests/ref/image-fit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
tests/ref/image-jpeg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
tests/ref/image-png.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

0
tests/typ/empty.typ Normal file
View File

View File

@ -1,3 +1,5 @@
// Small integration test of syntax, page setup, box layout and alignment.
[page: width=450pt, height=300pt, margins=1cm] [page: width=450pt, height=300pt, margins=1cm]
[box][ [box][

View File

@ -0,0 +1,8 @@
// error: 5:9-5:30 failed to load image
// error: 8:9-8:30 failed to load image
// File does not exist.
[image: "path/does/not/exist"]
// File exists, but is no image.
[image: "typ/image-error.typ"]

21
tests/typ/image-fit.typ Normal file
View File

@ -0,0 +1,21 @@
// Fit to width of page.
[image: "res/rhino.png"]
// Fit to height of page.
[page: width=270pt][
[image: "res/rhino.png"]
]
// Set width explicitly.
[image: "res/rhino.png", width=50pt]
// Set height explicitly.
[image: "res/rhino.png", height=50pt]
// Set width and height explicitly and force stretching.
[image: "res/rhino.png", width=25pt, height=50pt]
// Make sure the bounding-box of the image is correct.
[align: bottom, right][
[image: "res/tiger.jpg"]
]

2
tests/typ/image-jpeg.typ Normal file
View File

@ -0,0 +1,2 @@
// Load an RGB JPEG image.
[image: "res/tiger.jpg"]

2
tests/typ/image-png.typ Normal file
View File

@ -0,0 +1,2 @@
// Load an RGBA PNG image.
[image: "res/rhino.png"]

View File

@ -1,15 +0,0 @@
[page: width=5cm, height=5cm, margins=0.25cm]
[image: "res/tiger.jpg"]
[pagebreak]
# Tiger
[image: "res/tiger.jpg", width=2cm]
[image: "res/rhino.png", width=1cm]
[image: "res/rhino.png", height=2cm]
[pagebreak]
[align: center, bottom]
[image: "res/tiger.jpg", width=2cm, height=3.5cm]

View File

@ -1,35 +1,35 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::env; use std::env;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs::{self, File}; use std::fs;
use std::path::Path; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use fontdock::fs::{FsIndex, FsSource}; use fontdock::fs::{FsIndex, FsSource};
use image::{GenericImageView, Rgba}; use image::{GenericImageView, Rgba};
use memmap::Mmap;
use tiny_skia::{ use tiny_skia::{
Canvas, Color, ColorU8, FillRule, FilterQuality, Paint, PathBuilder, Pattern, Pixmap, Canvas, Color, ColorU8, FillRule, FilterQuality, Paint, PathBuilder, Pattern, Pixmap,
Rect, SpreadMode, Transform, Rect, SpreadMode, Transform,
}; };
use ttf_parser::OutlineBuilder; use ttf_parser::OutlineBuilder;
use typst::diag::{Feedback, Pass}; use typst::diag::{Diag, Feedback, Level, Pass};
use typst::env::{Env, ImageResource, ResourceLoader, SharedEnv}; use typst::env::{Env, ImageResource, ResourceLoader, SharedEnv};
use typst::eval::State; use typst::eval::State;
use typst::export::pdf; use typst::export::pdf;
use typst::font::FontLoader; use typst::font::FontLoader;
use typst::geom::{Length, Point}; use typst::geom::{Length, Point, Sides, Size};
use typst::layout::{BoxLayout, ImageElement, LayoutElement}; use typst::layout::{BoxLayout, ImageElement, LayoutElement};
use typst::parse::LineMap; use typst::parse::{LineMap, Scanner};
use typst::shaping::Shaped; use typst::shaping::Shaped;
use typst::syntax::{Location, Pos, SpanVec, SpanWith, Spanned};
use typst::typeset; use typst::typeset;
const FONT_DIR: &str = "../fonts";
const TYP_DIR: &str = "typ"; const TYP_DIR: &str = "typ";
const PDF_DIR: &str = "pdf"; const REF_DIR: &str = "ref";
const PNG_DIR: &str = "png"; const PNG_DIR: &str = "out/png";
const CMP_DIR: &str = "cmp"; const PDF_DIR: &str = "out/pdf";
const FONT_DIR: &str = "../fonts";
fn main() { fn main() {
env::set_current_dir(env::current_dir().unwrap().join("tests")).unwrap(); env::set_current_dir(env::current_dir().unwrap().join("tests")).unwrap();
@ -44,12 +44,8 @@ fn main() {
} }
let name = src_path.file_stem().unwrap().to_string_lossy().to_string(); let name = src_path.file_stem().unwrap().to_string_lossy().to_string();
let pdf_path = Path::new(PDF_DIR).join(&name).with_extension("pdf");
let png_path = Path::new(PNG_DIR).join(&name).with_extension("png");
let ref_path = Path::new(CMP_DIR).join(&name).with_extension("png");
if filter.matches(&name) { if filter.matches(&name) {
filtered.push((name, src_path, pdf_path, png_path, ref_path)); filtered.push((name, src_path));
} }
} }
@ -62,8 +58,8 @@ fn main() {
println!("Running {} tests", len); println!("Running {} tests", len);
} }
fs::create_dir_all(PDF_DIR).unwrap();
fs::create_dir_all(PNG_DIR).unwrap(); fs::create_dir_all(PNG_DIR).unwrap();
fs::create_dir_all(PDF_DIR).unwrap();
let mut index = FsIndex::new(); let mut index = FsIndex::new();
index.search_dir(FONT_DIR); index.search_dir(FONT_DIR);
@ -76,29 +72,12 @@ fn main() {
let mut ok = true; let mut ok = true;
for (name, src_path, pdf_path, png_path, ref_path) in filtered { for (name, src_path) in filtered {
print!("Testing {}.", name); let png_path = Path::new(PNG_DIR).join(&name).with_extension("png");
test(&src_path, &pdf_path, &png_path, &env); let pdf_path = Path::new(PDF_DIR).join(&name).with_extension("pdf");
let ref_path = Path::new(REF_DIR).join(&name).with_extension("png");
let png_file = File::open(&png_path).unwrap(); ok &= test(&name, &src_path, &pdf_path, &png_path, &ref_path, &env);
let ref_file = match File::open(&ref_path) {
Ok(file) => file,
Err(_) => {
println!(" Failed to open reference image. ❌");
ok = false;
continue;
}
};
let a = unsafe { Mmap::map(&png_file).unwrap() };
let b = unsafe { Mmap::map(&ref_file).unwrap() };
if *a != *b {
println!(" Does not match reference image. ❌");
ok = false;
} else {
println!(" Okay. ✔");
}
} }
if !ok { if !ok {
@ -106,41 +85,6 @@ fn main() {
} }
} }
fn test(src_path: &Path, pdf_path: &Path, png_path: &Path, env: &SharedEnv) {
let src = fs::read_to_string(src_path).unwrap();
let state = State::default();
let Pass {
output: layouts,
feedback: Feedback { mut diags, .. },
} = typeset(&src, Rc::clone(env), state);
if !diags.is_empty() {
diags.sort();
let map = LineMap::new(&src);
for diag in diags {
let span = diag.span;
let start = map.location(span.start);
let end = map.location(span.end);
println!(
" {}: {}:{}-{}: {}",
diag.v.level,
src_path.display(),
start,
end,
diag.v.message,
);
}
}
let env = env.borrow();
let canvas = draw(&layouts, &env, 2.0);
canvas.pixmap.save_png(png_path).unwrap();
let pdf_data = pdf::export(&layouts, &env);
fs::write(pdf_path, pdf_data).unwrap();
}
struct TestFilter { struct TestFilter {
filter: Vec<String>, filter: Vec<String>,
perfect: bool, perfect: bool,
@ -171,6 +115,111 @@ impl TestFilter {
} }
} }
fn test(
name: &str,
src_path: &Path,
pdf_path: &Path,
png_path: &Path,
ref_path: &Path,
env: &SharedEnv,
) -> bool {
println!("Testing {}.", name);
let src = fs::read_to_string(src_path).unwrap();
let map = LineMap::new(&src);
let ref_diags = parse_diags(&src, &map);
let mut state = State::default();
state.page.size = Size::uniform(Length::pt(120.0));
state.page.margins = Sides::uniform(Some(Length::pt(10.0).into()));
let Pass {
output: layouts,
feedback: Feedback { mut diags, .. },
} = typeset(&src, Rc::clone(env), state);
diags.sort();
let env = env.borrow();
let canvas = draw(&layouts, &env, 2.0);
canvas.pixmap.save_png(png_path).unwrap();
let pdf_data = pdf::export(&layouts, &env);
fs::write(pdf_path, pdf_data).unwrap();
let mut ok = true;
if diags != ref_diags {
println!(" Does not match expected diagnostics. ❌");
ok = false;
for diag in &diags {
if ref_diags.binary_search(diag).is_err() {
print!(" Unexpected | ");
print_diag(diag, &map);
}
}
for diag in &ref_diags {
if diags.binary_search(diag).is_err() {
print!(" Missing | ");
print_diag(diag, &map);
}
}
}
if let Ok(ref_pixmap) = Pixmap::load_png(&ref_path) {
if canvas.pixmap != ref_pixmap {
println!(" Does not match reference image. ❌");
ok = false;
}
} else {
println!(" Failed to open reference image. ❌");
ok = false;
}
if ok {
println!("\x1b[1ATesting {}. ✔", name);
}
ok
}
fn parse_diags(src: &str, map: &LineMap) -> SpanVec<Diag> {
let mut diags = vec![];
for line in src.lines() {
let (level, rest) = if let Some(rest) = line.strip_prefix("// error: ") {
(Level::Error, rest)
} else if let Some(rest) = line.strip_prefix("// warning: ") {
(Level::Warning, rest)
} else {
continue;
};
fn pos(s: &mut Scanner, map: &LineMap) -> Pos {
let (line, _, column) = (num(s), s.eat_assert(':'), num(s));
map.pos(Location { line, column }).unwrap()
}
fn num(s: &mut Scanner) -> u32 {
s.eat_while(|c| c.is_numeric()).parse().unwrap()
}
let mut s = Scanner::new(rest);
let (start, _, end) = (pos(&mut s, map), s.eat_assert('-'), pos(&mut s, map));
diags.push(Diag::new(level, s.rest().trim()).span_with(start .. end));
}
diags.sort();
diags
}
fn print_diag(diag: &Spanned<Diag>, map: &LineMap) {
let start = map.location(diag.span.start).unwrap();
let end = map.location(diag.span.end).unwrap();
println!("{}: {}-{}: {}", diag.v.level, start, end, diag.v.message,);
}
fn draw(layouts: &[BoxLayout], env: &Env, pixel_per_pt: f32) -> Canvas { fn draw(layouts: &[BoxLayout], env: &Env, pixel_per_pt: f32) -> Canvas {
let pad = Length::pt(5.0); let pad = Length::pt(5.0);