summaryrefslogtreecommitdiff
path: root/tests/src
diff options
context:
space:
mode:
Diffstat (limited to 'tests/src')
-rw-r--r--tests/src/layouter.rs192
-rw-r--r--tests/src/parser.rs311
-rw-r--r--tests/src/render.py193
-rw-r--r--tests/src/spanless.rs62
4 files changed, 758 insertions, 0 deletions
diff --git a/tests/src/layouter.rs b/tests/src/layouter.rs
new file mode 100644
index 00000000..6d38666b
--- /dev/null
+++ b/tests/src/layouter.rs
@@ -0,0 +1,192 @@
+use std::collections::HashMap;
+use std::error::Error;
+use std::ffi::OsStr;
+use std::fs::{File, create_dir_all, read_dir, read_to_string};
+use std::io::{BufWriter, Write};
+use std::panic;
+use std::process::Command;
+
+use futures_executor::block_on;
+
+use typstc::Typesetter;
+use typstc::layout::{MultiLayout, Serialize};
+use typstc::size::{Size, Size2D};
+use typstc::style::PageStyle;
+use typstc::toddle::query::FileSystemFontProvider;
+use typstc::export::pdf::PdfExporter;
+
+
+type DynResult<T> = Result<T, Box<dyn Error>>;
+
+fn main() -> DynResult<()> {
+ let opts = Options::parse();
+
+ create_dir_all("tests/cache/serial")?;
+ create_dir_all("tests/cache/render")?;
+ create_dir_all("tests/cache/pdf")?;
+
+ let tests: Vec<_> = read_dir("tests/layouter/")?.collect();
+ let mut filtered = Vec::new();
+
+ for entry in tests {
+ let path = entry?.path();
+ if path.extension() != Some(OsStr::new("typ")) {
+ continue;
+ }
+
+ let name = path
+ .file_stem().ok_or("expected file stem")?
+ .to_string_lossy()
+ .to_string();
+
+ if opts.matches(&name) {
+ let src = read_to_string(&path)?;
+ filtered.push((name, src));
+ }
+ }
+
+ let len = filtered.len();
+ println!();
+ println!("Running {} test{}", len, if len > 1 { "s" } else { "" });
+
+ for (name, src) in filtered {
+ panic::catch_unwind(|| {
+ if let Err(e) = test(&name, &src) {
+ println!("error: {}", e);
+ }
+ }).ok();
+ }
+
+ println!();
+
+ Ok(())
+}
+
+/// Create a _PDF_ with a name from the source code.
+fn test(name: &str, src: &str) -> DynResult<()> {
+ println!("Testing: {}.", name);
+
+ let mut typesetter = Typesetter::new();
+ typesetter.set_page_style(PageStyle {
+ dimensions: Size2D::with_all(Size::pt(250.0)),
+ .. PageStyle::default()
+ });
+
+ let provider = FileSystemFontProvider::from_index("../fonts/index.json")?;
+ let font_paths = provider.paths();
+ typesetter.add_font_provider(provider);
+
+ let layouts = match compile(&typesetter, src) {
+ Some(layouts) => layouts,
+ None => return Ok(()),
+ };
+
+ // Compute the font's paths.
+ let mut fonts = HashMap::new();
+ let loader = typesetter.loader().borrow();
+ for layout in &layouts {
+ for index in layout.find_used_fonts() {
+ fonts.entry(index).or_insert_with(|| {
+ let p = loader.get_provider_and_index(index.id).1;
+ &font_paths[p][index.variant]
+ });
+ }
+ }
+ drop(loader);
+
+ // Write the serialized layout file.
+ let path = format!("tests/cache/serial/{}", name);
+ let mut file = BufWriter::new(File::create(path)?);
+
+ // Write the font mapping into the serialization file.
+ writeln!(file, "{}", fonts.len())?;
+ for (index, path) in fonts.iter() {
+ writeln!(file, "{} {} {}", index.id, index.variant, path)?;
+ }
+ layouts.serialize(&mut file)?;
+
+ // Render the layout into a PNG.
+ Command::new("python")
+ .arg("tests/render.py")
+ .arg(name)
+ .spawn()
+ .expect("failed to run python renderer");
+
+ // Write the PDF file.
+ let path = format!("tests/cache/pdf/{}.pdf", name);
+ let file = BufWriter::new(File::create(path)?);
+ let exporter = PdfExporter::new();
+ exporter.export(&layouts, typesetter.loader(), file)?;
+
+ Ok(())
+}
+
+/// Compile the source code with the typesetter.
+fn compile(typesetter: &Typesetter, src: &str) -> Option<MultiLayout> {
+ #[cfg(not(debug_assertions))] {
+ use std::time::Instant;
+
+ // Warmup.
+ let warmup_start = Instant::now();
+ let is_ok = block_on(typesetter.typeset(&src)).is_ok();
+ let warmup_end = Instant::now();
+
+ // Only continue if the typesetting was successful.
+ if is_ok {
+ let start = Instant::now();
+ let tree = typesetter.parse(&src).unwrap();
+ let mid = Instant::now();
+ block_on(typesetter.layout(&tree)).unwrap();
+ let end = Instant::now();
+
+ println!(" - cold start: {:?}", warmup_end - warmup_start);
+ println!(" - warmed up: {:?}", end - start);
+ println!(" - parsing: {:?}", mid - start);
+ println!(" - layouting: {:?}", end - mid);
+ println!();
+ }
+ };
+
+ match block_on(typesetter.typeset(&src)) {
+ Ok(layouts) => Some(layouts),
+ Err(err) => {
+ println!(" - compilation failed: {}", err);
+ #[cfg(not(debug_assertions))]
+ println!();
+ None
+ }
+ }
+}
+
+/// Command line options.
+struct Options {
+ filter: Vec<String>,
+ perfect: bool,
+}
+
+impl Options {
+ /// Parse the options from the environment arguments.
+ fn parse() -> Options {
+ let mut perfect = false;
+ let mut filter = Vec::new();
+
+ for arg in std::env::args().skip(1) {
+ match arg.as_str() {
+ "--nocapture" => {},
+ "=" => perfect = true,
+ _ => filter.push(arg),
+ }
+ }
+
+ Options { filter, perfect }
+ }
+
+ /// Whether a given test should be executed.
+ fn matches(&self, name: &str) -> bool {
+ match self.perfect {
+ true => self.filter.iter().any(|p| name == p),
+ false => self.filter.is_empty()
+ || self.filter.iter().any(|p| name.contains(p))
+ }
+ }
+}
diff --git a/tests/src/parser.rs b/tests/src/parser.rs
new file mode 100644
index 00000000..ecf1544c
--- /dev/null
+++ b/tests/src/parser.rs
@@ -0,0 +1,311 @@
+use std::fmt::Debug;
+
+use typstc::func::Scope;
+use typstc::size::Size;
+use typstc::syntax::*;
+use typstc::{function, parse};
+
+mod spanless;
+use spanless::SpanlessEq;
+
+
+/// The result of a single test case.
+enum Case {
+ Okay,
+ Failed {
+ line: usize,
+ src: &'static str,
+ expected: String,
+ found: String,
+ }
+}
+
+/// Test all tests.
+fn test(tests: Vec<(&str, Vec<Case>)>) {
+ println!();
+
+ let mut errors = false;
+
+ let len = tests.len();
+ println!("Running {} test{}", len, if len > 1 { "s" } else { "" });
+
+ for (file, cases) in tests {
+ print!("Testing: {}. ", file);
+
+ let mut okay = 0;
+ let mut failed = 0;
+
+ for case in cases {
+ match case {
+ Case::Okay => okay += 1,
+ Case::Failed { line, src, expected, found } => {
+ println!();
+ println!(" - Case failed in file {}.rs in line {}.", file, line);
+ println!(" - Source: {:?}", src);
+ println!(" - Expected: {}", expected);
+ println!(" - Found: {}", found);
+
+ failed += 1;
+ }
+ }
+ }
+
+ // Print a small summary.
+ print!("{} okay, {} failed.", okay, failed);
+ if failed == 0 {
+ print!(" ✔")
+ } else {
+ errors = true;
+ }
+
+ println!();
+ }
+
+ println!();
+
+ if errors {
+ std::process::exit(-1);
+ }
+}
+
+/// The main test macro.
+macro_rules! tokens {
+ ($($task:ident $src:expr =>($line:expr)=> [$($e:tt)*])*) => ({
+ vec![$({
+ let (okay, expected, found) = case!($task $src, [$($e)*]);
+ if okay {
+ Case::Okay
+ } else {
+ Case::Failed {
+ line: $line,
+ src: $src,
+ expected: format(expected),
+ found: format(found),
+ }
+ }
+ }),*]
+ });
+}
+
+//// Indented formatting for failed cases.
+fn format(thing: impl Debug) -> String {
+ format!("{:#?}", thing).replace('\n', "\n ")
+}
+
+/// Evaluates a single test.
+macro_rules! case {
+ (t $($rest:tt)*) => (case!(@tokenize SpanlessEq::spanless_eq, $($rest)*));
+ (ts $($rest:tt)*) => (case!(@tokenize PartialEq::eq, $($rest)*));
+
+ (@tokenize $cmp:expr, $src:expr, [$($e:tt)*]) => ({
+ let expected = list!(tokens [$($e)*]);
+ let found = tokenize($src).collect::<Vec<_>>();
+ ($cmp(&found, &expected), expected, found)
+ });
+
+ (p $($rest:tt)*) => (case!(@parse SpanlessEq::spanless_eq, $($rest)*));
+ (ps $($rest:tt)*) => (case!(@parse PartialEq::eq, $($rest)*));
+
+ (@parse $cmp:expr, $src:expr, [$($e:tt)*]) => ({
+ let expected = SyntaxTree { nodes: list!(nodes [$($e)*]) };
+ let found = parse($src, ParseContext { scope: &scope() }).0;
+ ($cmp(&found, &expected), expected, found)
+ });
+
+ (c $src:expr, [$($e:tt)*]) => ({
+ let expected = Colorization { tokens: list!(colors [$($e)*]) };
+ let found = parse($src, ParseContext { scope: &scope() }).1;
+ (expected == found, expected, found)
+ });
+
+ (e $src:expr, [$($e:tt)*]) => ({
+ let expected = ErrorMap { errors: list!([$($e)*]) };
+ let found = parse($src, ParseContext { scope: &scope() }).2;
+ (expected == found, expected, found)
+ });
+}
+
+/// A scope containing the `DebugFn` as a fallback.
+fn scope() -> Scope {
+ Scope::with_debug::<DebugFn>()
+}
+
+/// Parses possibly-spanned lists of token or node expressions.
+macro_rules! list {
+ (expr [$($item:expr),* $(,)?]) => ({
+ #[allow(unused_imports)]
+ use cuts::expr::*;
+ Tuple { items: vec![$(zspan($item)),*] }
+ });
+
+ (expr [$($key:expr =>($_:expr)=> $value:expr),* $(,)?]) => ({
+ #[allow(unused_imports)]
+ use cuts::expr::*;
+ Object {
+ pairs: vec![$(Pair {
+ key: zspan(Ident($key.to_string())),
+ value: zspan($value),
+ }),*]
+ }
+ });
+
+ ($cut:ident [$($e:tt)*]) => ({
+ #[allow(unused_imports)]
+ use cuts::$cut::*;
+ list!([$($e)*])
+ });
+
+ ([$(($sl:tt:$sc:tt, $el:tt:$ec:tt, $v:expr)),* $(,)?]) => ({
+ vec![
+ $(Spanned { v: $v, span: Span {
+ start: Position { line: $sl, column: $sc },
+ end: Position { line: $el, column: $ec },
+ }}),*
+ ]
+ });
+
+ ([$($e:tt)*]) => (vec![$($e)*].into_iter().map(zspan).collect::<Vec<_>>());
+}
+
+/// Composes a function expression.
+macro_rules! func {
+ ($name:expr $(,pos: [$($p:tt)*])? $(,key: [$($k:tt)*])?; $($b:tt)*) => ({
+ #![allow(unused_mut, unused_assignments)]
+
+ let mut positional = Tuple::new();
+ let mut keyword = Object::new();
+
+ $(positional = list!(expr [$($p)*]);)?
+ $(keyword = list!(expr [$($k)*]);)?
+
+ Node::Func(FuncCall(Box::new(DebugFn {
+ header: FuncHeader {
+ name: zspan(Ident($name.to_string())),
+ args: FuncArgs {
+ positional,
+ keyword,
+ },
+ },
+ body: func!(@body $($b)*),
+ })))
+ });
+
+ (@body Some($($b:tt)*)) => (Some(SyntaxTree { nodes: list!(nodes $($b)*) }));
+ (@body None) => (None);
+}
+
+function! {
+ /// Most functions in the tests are parsed into the debug function for easy
+ /// inspection of arguments and body.
+ #[derive(Debug, PartialEq)]
+ pub struct DebugFn {
+ header: FuncHeader,
+ body: Option<SyntaxTree>,
+ }
+
+ parse(header, body, ctx) {
+ DebugFn {
+ header: header.clone(),
+ body: parse!(optional: body, ctx),
+ }
+ }
+
+ layout() { vec![] }
+}
+
+/// Span an element with a zero span.
+fn zspan<T>(v: T) -> Spanned<T> {
+ Spanned { v, span: Span::ZERO }
+}
+
+/// Abbreviations for tokens, nodes, colors and expressions.
+#[allow(non_snake_case, dead_code)]
+mod cuts {
+ pub mod tokens {
+ pub use typstc::syntax::Token::{
+ Whitespace as W,
+ LineComment as LC,
+ BlockComment as BC,
+ StarSlash as SS,
+ LeftBracket as LB,
+ RightBracket as RB,
+ LeftParen as LP,
+ RightParen as RP,
+ LeftBrace as LBR,
+ RightBrace as RBR,
+ Colon as CL,
+ Comma as CM,
+ Equals as EQ,
+ ExprIdent as ID,
+ ExprStr as STR,
+ ExprSize as SIZE,
+ ExprNumber as NUM,
+ ExprBool as BOOL,
+ Star as S,
+ Underscore as U,
+ Backtick as B,
+ Text as T,
+ };
+ }
+
+ pub mod nodes {
+ use typstc::syntax::Node;
+
+ pub use Node::{
+ Space as S,
+ Newline as N,
+ ToggleItalic as I,
+ ToggleBolder as B,
+ ToggleMonospace as M,
+ };
+
+ pub fn T(text: &str) -> Node {
+ Node::Text(text.to_string())
+ }
+ }
+
+ pub mod colors {
+ pub use typstc::syntax::ColorToken::{
+ Comment as C,
+ Bracket as B,
+ FuncName as FN,
+ Colon as CL,
+ Key as K,
+ Equals as EQ,
+ Comma as CM,
+ Paren as P,
+ Brace as BR,
+ ExprIdent as ID,
+ ExprStr as STR,
+ ExprNumber as NUM,
+ ExprSize as SIZE,
+ ExprBool as BOOL,
+ Bold as BD,
+ Italic as IT,
+ Monospace as MS,
+ Invalid as INV,
+ };
+ }
+
+ pub mod expr {
+ use typstc::syntax::{Expression, Ident};
+
+ pub use Expression::{
+ Number as NUM,
+ Size as SIZE,
+ Bool as BOOL,
+ };
+
+ pub fn ID(text: &str) -> Expression {
+ Expression::Ident(Ident(text.to_string()))
+ }
+
+ pub fn STR(text: &str) -> Expression {
+ Expression::Str(text.to_string())
+ }
+ }
+}
+
+fn main() {
+ test(include!("../cache/parser-tests.rs"))
+}
diff --git a/tests/src/render.py b/tests/src/render.py
new file mode 100644
index 00000000..bb27e973
--- /dev/null
+++ b/tests/src/render.py
@@ -0,0 +1,193 @@
+import sys
+import os
+import pathlib
+import math
+import numpy
+from PIL import Image, ImageDraw, ImageFont
+
+
+BASE = os.path.dirname(__file__)
+CACHE = os.path.join(BASE, '../cache/')
+SERIAL = os.path.join(CACHE, 'serial/')
+RENDER = os.path.join(CACHE, 'render/')
+
+
+def main():
+ assert len(sys.argv) == 2, 'usage: python render.py <name>'
+ name = sys.argv[1]
+
+ filename = os.path.join(SERIAL, name)
+ with open(filename, encoding='utf-8') as file:
+ lines = [line[:-1] for line in file.readlines()]
+
+ renderer = MultiboxRenderer(lines)
+ renderer.render()
+ image = renderer.export()
+
+ pathlib.Path(RENDER).mkdir(parents=True, exist_ok=True)
+ image.save(os.path.join(RENDER, f'{name}.png'))
+
+
+class MultiboxRenderer:
+ def __init__(self, lines):
+ self.combined = None
+
+ self.fonts = {}
+ font_count = int(lines[0])
+ for i in range(font_count):
+ parts = lines[i + 1].split(' ', 2)
+ index = int(parts[0]), int(parts[1])
+ path = parts[2]
+ self.fonts[index] = os.path.join(BASE, '../../fonts', path)
+
+ self.content = lines[font_count + 1:]
+
+ def render(self):
+ images = []
+
+ layout_count = int(self.content[0])
+ horizontal = math.floor(math.sqrt(layout_count))
+ start = 1
+
+ for _ in range(layout_count):
+ width, height = (float(s) for s in self.content[start].split())
+ action_count = int(self.content[start + 1])
+ start += 2
+
+ renderer = BoxRenderer(self.fonts, width, height)
+ for i in range(action_count):
+ command = self.content[start + i]
+ renderer.execute(command)
+
+ images.append(renderer.export())
+ start += action_count
+
+ i = 0
+ x = 10
+ y = 10
+ width = 10
+ row_height = 0
+
+ positions = []
+
+ for image in images:
+ positions.append((x, y))
+
+ x += 10 + image.width
+ row_height = max(row_height, image.height)
+
+ i += 1
+ if i >= horizontal:
+ width = max(width, x)
+ x = 10
+ y += 10 + row_height
+ i = 0
+ row_height = 0
+
+ height = y
+ if i != 0:
+ height += 10 + row_height
+
+ self.combined = Image.new('RGBA', (width, height))
+
+ for (position, image) in zip(positions, images):
+ self.combined.paste(image, position)
+
+ def export(self):
+ return self.combined
+
+
+class BoxRenderer:
+ def __init__(self, fonts, width, height, grid=False):
+ self.fonts = fonts
+ self.size = (pix(width), pix(height))
+
+ img = Image.new('RGBA', self.size, (255, 255, 255, 255))
+ pixels = numpy.array(img)
+
+ if grid:
+ for i in range(0, int(height)):
+ for j in range(0, int(width)):
+ if ((i // 2) % 2 == 0) == ((j // 2) % 2 == 0):
+ pixels[4*i:4*(i+1), 4*j:4*(j+1)] = (225, 225, 225, 255)
+
+ self.img = Image.fromarray(pixels, 'RGBA')
+ self.draw = ImageDraw.Draw(self.img)
+ self.cursor = (0, 0)
+
+ self.colors = [
+ (176, 264, 158),
+ (274, 173, 207),
+ (158, 252, 264),
+ (285, 275, 187),
+ (132, 217, 136),
+ (236, 177, 246),
+ (174, 232, 279),
+ (285, 234, 158)
+ ]
+
+ self.rects = []
+ self.color_index = 0
+
+ def execute(self, command):
+ cmd = command[0]
+ parts = command.split()[1:]
+
+ if cmd == 'm':
+ x, y = (pix(float(s)) for s in parts)
+ self.cursor = [x, y]
+
+ elif cmd == 'f':
+ index = int(parts[0]), int(parts[1])
+ size = pix(float(parts[2]))
+ self.font = ImageFont.truetype(self.fonts[index], size)
+
+ elif cmd == 'w':
+ text = command[2:]
+ width = self.draw.textsize(text, font=self.font)[0]
+ self.draw.text(self.cursor, text, (0, 0, 0, 255), font=self.font)
+ self.cursor[0] += width
+
+ elif cmd == 'b':
+ x, y = self.cursor
+ w, h = (pix(float(s)) for s in parts)
+ rect = [x, y, x+w-1, y+h-1]
+
+ forbidden_colors = set()
+ for other_rect, other_color in self.rects:
+ if rect == other_rect:
+ return
+
+ if overlap(rect, other_rect) or overlap(other_rect, rect):
+ forbidden_colors.add(other_color)
+
+ for color in self.colors[self.color_index:] + self.colors[:self.color_index]:
+ self.color_index = (self.color_index + 1) % len(self.colors)
+ if color not in forbidden_colors:
+ break
+
+ overlay = Image.new('RGBA', self.size, (0, 0, 0, 0))
+ draw = ImageDraw.Draw(overlay)
+ draw.rectangle(rect, fill=color + (255,))
+
+ self.img = Image.alpha_composite(self.img, overlay)
+ self.draw = ImageDraw.Draw(self.img)
+
+ self.rects.append((rect, color))
+
+ else:
+ raise Exception('invalid command')
+
+ def export(self):
+ return self.img
+
+
+def pix(points):
+ return int(4 * points)
+
+def overlap(a, b):
+ return (a[0] < b[2] and b[0] < a[2]) and (a[1] < b[3] and b[1] < a[3])
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tests/src/spanless.rs b/tests/src/spanless.rs
new file mode 100644
index 00000000..fde5a2ed
--- /dev/null
+++ b/tests/src/spanless.rs
@@ -0,0 +1,62 @@
+use super::*;
+
+
+/// Compares elements by only looking at values and ignoring spans.
+pub trait SpanlessEq<T> {
+ fn spanless_eq(&self, other: &T) -> bool;
+}
+
+impl SpanlessEq<Vec<Spanned<Token<'_>>>> for Vec<Spanned<Token<'_>>> {
+ fn spanless_eq(&self, other: &Vec<Spanned<Token>>) -> bool {
+ self.len() == other.len()
+ && self.iter().zip(other).all(|(x, y)| x.v == y.v)
+ }
+}
+
+impl SpanlessEq<SyntaxTree> for SyntaxTree {
+ fn spanless_eq(&self, other: &SyntaxTree) -> bool {
+ fn downcast(func: &FuncCall) -> &DebugFn {
+ func.0.downcast::<DebugFn>().expect("not a debug fn")
+ }
+
+ self.nodes.len() == other.nodes.len()
+ && self.nodes.iter().zip(&other.nodes).all(|(x, y)| match (&x.v, &y.v) {
+ (Node::Func(a), Node::Func(b)) => downcast(a).spanless_eq(downcast(b)),
+ (a, b) => a == b,
+ })
+ }
+}
+
+impl SpanlessEq<DebugFn> for DebugFn {
+ fn spanless_eq(&self, other: &DebugFn) -> bool {
+ self.header.name.v == other.header.name.v
+ && self.header.args.positional.spanless_eq(&other.header.args.positional)
+ && self.header.args.keyword.spanless_eq(&other.header.args.keyword)
+ }
+}
+
+impl SpanlessEq<Expression> for Expression {
+ fn spanless_eq(&self, other: &Expression) -> bool {
+ match (self, other) {
+ (Expression::Tuple(a), Expression::Tuple(b)) => a.spanless_eq(b),
+ (Expression::Object(a), Expression::Object(b)) => a.spanless_eq(b),
+ (a, b) => a == b,
+ }
+ }
+}
+
+impl SpanlessEq<Tuple> for Tuple {
+ fn spanless_eq(&self, other: &Tuple) -> bool {
+ self.items.len() == other.items.len()
+ && self.items.iter().zip(&other.items)
+ .all(|(x, y)| x.v.spanless_eq(&y.v))
+ }
+}
+
+impl SpanlessEq<Object> for Object {
+ fn spanless_eq(&self, other: &Object) -> bool {
+ self.pairs.len() == other.pairs.len()
+ && self.pairs.iter().zip(&other.pairs)
+ .all(|(x, y)| x.key.v == y.key.v && x.value.v.spanless_eq(&y.value.v))
+ }
+}