use std::{
collections::HashMap,
env::temp_dir,
fmt::{Debug, Display, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
time::SystemTime,
};
use crate::{
DiagnosticSet, GlyphIdent, GlyphMap, ParseTree,
compile::{
Compiler, FeatureProvider, MockVariationInfo, Opts, PendingLookup, error::CompilerError,
},
};
use ansi_term::Color;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use write_fonts::{
tables::{
gpos::builders::{AnchorBuilder, MarkToBaseBuilder, PairPosBuilder, ValueRecordBuilder},
layout::LookupFlag,
},
types::{GlyphId16, Tag},
};
use super::FEA_FILTER_TESTS;
static IGNORED_TESTS: &[&str] = &[
"AlternateChained.fea",
"GSUB_6.fea",
"GSUB_2.fea",
"GSUB_8.fea",
"variable_bug2772.fea",
"variable_scalar_anchor.fea",
"variable_scalar_valuerecord.fea",
"variable_mark_anchor.fea",
];
static TEMP_DIR_ENV: &str = "TTX_TEMP_DIR";
#[derive(Default, Serialize, Deserialize)]
pub struct Report {
pub results: Vec<TestCase>,
}
#[derive(Default)]
struct ReportSummary {
passed: u32,
panic: u32,
parse: u32,
compile: u32,
compare: u32,
other: u32,
sum_compare_perc: f64,
}
struct ReportComparePrinter<'a> {
old: &'a Report,
new: &'a Report,
}
#[derive(Serialize, Deserialize)]
pub struct TestCase {
pub path: PathBuf,
pub reason: TestResult,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub enum TestResult {
Success,
Panic,
ParseFail(String),
CompileFail(String),
UnexpectedSuccess,
#[allow(missing_docs)]
TtxFail { code: Option<i32>, std_err: String },
#[allow(missing_docs)]
CompareFail {
expected: String,
result: String,
diff_percent: f64,
},
#[allow(missing_docs)]
ExpectedDiffFail { expected: String, result: String },
}
struct ReasonPrinter<'a> {
verbose: bool,
reason: &'a TestResult,
}
pub fn assert_has_ttx_executable() {
assert!(
Command::new("ttx")
.arg("--version")
.stdout(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false),
"\nmissing `ttx` executable. Install it with `pip install fonttools`."
)
}
#[derive(Clone, Debug, Default)]
pub struct Filter(Vec<String>);
impl Filter {
pub fn from_env() -> Self {
Self::new(std::env::var(FEA_FILTER_TESTS).ok())
}
pub fn new(input: Option<String>) -> Self {
Self(
input
.map(|s| {
s.split(',')
.map(|s| s.trim().to_owned())
.collect::<Vec<_>>()
})
.unwrap_or_default(),
)
}
pub fn filter(&self, item: &Path) -> bool {
let str_item = item.to_str().unwrap_or_default();
self.0.is_empty() || self.0.iter().any(|needle| str_item.contains(needle))
}
}
pub fn run_fonttools_tests(filter: Option<String>) -> Report {
let fonttools_data_dir = test_data_dir().join("fonttools-tests");
let glyph_map = fonttools_test_glyph_order();
let filter = Filter::new(filter);
let var_info = make_var_info();
let result = iter_fea_files(&fonttools_data_dir, filter)
.filter(|test| {
test.with_extension("ttx").exists()
&& !IGNORED_TESTS.contains(&test.file_name().unwrap().to_str().unwrap())
})
.par_bridge()
.map(|path| run_test(path, &glyph_map, &var_info))
.collect::<Vec<_>>();
finalize_results(result)
}
pub fn finalize_results(result: Vec<Result<PathBuf, TestCase>>) -> Report {
let mut result = result
.into_iter()
.fold(Report::default(), |mut results, current| {
match current {
Err(e) => results.results.push(e),
Ok(path) => results.results.push(TestCase {
path,
reason: TestResult::Success,
}),
}
results
});
result.results.sort_unstable_by(|a, b| {
(a.reason.sort_order(), &a.path).cmp(&(b.reason.sort_order(), &b.path))
});
result
}
fn is_fea(path: &Path) -> bool {
let Some(pstr) = path.file_name().and_then(|n| n.to_str()) else {
panic!("Compile tests should use stringable names");
};
pstr.ends_with(".fea")
}
pub fn iter_fea_files(
path: impl AsRef<Path>,
filter: Filter,
) -> impl Iterator<Item = PathBuf> + 'static {
let path = path.as_ref();
let mut dir = path.read_dir().ok();
std::iter::from_fn(move || {
loop {
let entry = dir.as_mut()?.next()?.unwrap();
let path = entry.path();
if is_fea(&path) && filter.filter(&path) {
return Some(path);
}
}
})
}
pub fn try_parse_file(
path: &Path,
glyphs: Option<&GlyphMap>,
) -> Result<ParseTree, (ParseTree, DiagnosticSet)> {
let (tree, errs) = crate::parse::parse_root_file(path, glyphs, None).unwrap();
if errs.has_errors() {
Err((tree, errs))
} else {
print_diagnostics_if_verbose(&errs);
Ok(tree)
}
}
pub(crate) fn is_variable(test_path: &Path) -> bool {
let as_str = test_path.to_str().expect("paths are utf8");
as_str.contains("variable") || as_str.contains("variation")
}
pub(crate) fn needs_feature_provider(test_path: &Path) -> bool {
let as_str = test_path.to_str().expect("paths are utf8");
as_str.contains("provider")
}
pub(crate) fn run_test(
path: PathBuf,
glyph_map: &GlyphMap,
fvar: &MockVariationInfo,
) -> Result<PathBuf, TestCase> {
let run_result = std::panic::catch_unwind(|| {
let mut compiler: Compiler<'_, TestFeatureProvider, MockVariationInfo> =
Compiler::new(path.clone(), glyph_map)
.print_warnings(std::env::var(super::VERBOSE).is_ok())
.with_opts(Opts::new().make_post_table(true));
if is_variable(&path) {
compiler = compiler.with_variable_info(fvar);
}
if needs_feature_provider(&path) {
compiler = compiler.with_feature_writer(&TestFeatureProvider)
}
match compiler.compile_binary() {
Err(CompilerError::SourceLoad(err)) => panic!("{err}"),
Err(CompilerError::WriteFail(err)) => panic!("{err}"),
Err(CompilerError::ParseFail(errs)) => Err(TestResult::ParseFail(errs.to_string(true))),
Err(CompilerError::ValidationFail(errs) | CompilerError::CompilationFail(errs)) => {
Err(TestResult::CompileFail(errs.to_string(true)))
}
Ok(result) => compare_ttx(&result, &path),
}
});
match run_result {
Err(_) => Err(TestResult::Panic),
Ok(Err(reason)) => Err(reason),
Ok(Ok(_)) => return Ok(path),
}
.map_err(|reason| TestCase { reason, path })
}
fn print_diagnostics_if_verbose(diagnostics: &DiagnosticSet) {
if std::env::var(super::VERBOSE).is_ok() && !diagnostics.is_empty() {
eprintln!("{}", diagnostics.display());
}
}
fn get_temp_dir() -> PathBuf {
match std::env::var(TEMP_DIR_ENV) {
Ok(dir) => {
let dir = PathBuf::from(dir);
if !dir.exists() {
std::fs::create_dir_all(&dir).unwrap();
}
dir
}
Err(_) => temp_dir(),
}
}
fn get_temp_file_name(in_file: &Path) -> PathBuf {
let stem = in_file.file_stem().unwrap().to_str().unwrap();
let millis = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis();
Path::new(&format!("{stem}_{millis}")).with_extension("ttf")
}
fn compare_ttx(font_data: &[u8], fea_path: &Path) -> Result<(), TestResult> {
let ttx_path = fea_path.with_extension("ttx");
let expected_diff_path = fea_path.with_extension("expected_diff");
let temp_path = get_temp_dir().join(get_temp_file_name(fea_path));
std::fs::write(&temp_path, font_data).unwrap();
const TO_WRITE: &[&str] = &[
"head", "name", "BASE", "GDEF", "GSUB", "GPOS", "OS/2", "STAT", "hhea", "vhea",
];
let mut cmd = Command::new("ttx");
for table in TO_WRITE {
cmd.arg("-t").arg(table);
}
let status = cmd
.arg(&temp_path)
.output()
.unwrap_or_else(|_| panic!("failed to execute for path {}", fea_path.display()));
if !status.status.success() {
let std_err = String::from_utf8_lossy(&status.stderr).into_owned();
return Err(TestResult::TtxFail {
code: status.status.code(),
std_err,
});
}
let ttx_out_path = temp_path.with_extension("ttx");
assert!(ttx_out_path.exists());
let result = std::fs::read_to_string(ttx_out_path).unwrap();
let result = rewrite_ttx(&result);
let expected = if ttx_path.exists() {
std::fs::read_to_string(&ttx_path).unwrap()
} else {
Default::default()
};
let expected = rewrite_ttx(&expected);
if expected_diff_path.exists() {
let expected_diff = std::fs::read_to_string(&expected_diff_path).unwrap();
let simple_diff = plain_text_diff(&expected, &result);
if diffs_are_equal_ignoring_comments(&expected_diff, &simple_diff) {
return Ok(());
} else {
return Err(TestResult::ExpectedDiffFail {
expected: expected_diff,
result: simple_diff,
});
}
}
if expected == result {
return Ok(());
}
if std::env::var(super::WRITE_RESULTS_VAR).is_ok() {
std::fs::write(&ttx_path, &result).unwrap();
}
let diff_percent = compute_diff_percentage(&expected, &result);
Err(TestResult::CompareFail {
expected,
result,
diff_percent,
})
}
fn diffs_are_equal_ignoring_comments(one: &str, two: &str) -> bool {
strip_comments(one) == strip_comments(two)
}
fn strip_comments(s: &str) -> String {
s.lines()
.skip_while(|line| {
let line = line.trim();
line.is_empty() || line.starts_with('#')
})
.collect()
}
pub fn compare_to_expected_output(
output: &str,
src_path: &Path,
cmp_ext: &str,
) -> Result<(), TestCase> {
let cmp_path = src_path.with_extension(cmp_ext);
let expected = if cmp_path.exists() {
std::fs::read_to_string(&cmp_path).expect("failed to read cmp_path")
} else {
String::new()
};
if expected != output {
eprintln!("{expected}\n\n{output}");
let diff_percent = compute_diff_percentage(&expected, output);
return Err(TestCase {
path: src_path.to_owned(),
reason: TestResult::CompareFail {
expected,
result: output.to_string(),
diff_percent,
},
});
}
Ok(())
}
fn rewrite_ttx(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for line in input.lines() {
if line.starts_with("<ttFont") {
out.push_str("<ttFont>\n");
} else if line.starts_with(" <checkSumAdjustment value=\"0x") {
out.push_str(" <checkSumAdjustment value=\"0x0\"/>\n");
} else {
out.push_str(line);
out.push('\n')
}
}
out
}
fn write_lines(f: &mut impl Write, lines: &[&str], line_num: usize, prefix: char) {
writeln!(f, "L{line_num}").unwrap();
for line in lines {
writeln!(f, "{prefix} {line}").unwrap();
}
}
static DIFF_PREAMBLE: &str = "\
# generated automatically by fea-rs
# this file represents an acceptable difference between the output of
# fonttools and the output of fea-rs for a given input.
";
fn compute_diff_percentage(left: &str, right: &str) -> f64 {
let lines = diff::lines(left, right);
let same = lines
.iter()
.filter(|l| matches!(l, diff::Result::Both { .. }))
.count();
let total = lines.len() as f64;
let perc = (same as f64) / total;
const PRECISION_SMUDGE: f64 = 10000.0;
(perc * PRECISION_SMUDGE).trunc() / PRECISION_SMUDGE
}
pub fn plain_text_diff(left: &str, right: &str) -> String {
let lines = diff::lines(left, right);
let mut result = DIFF_PREAMBLE.to_string();
let mut temp: Vec<&str> = Vec::new();
let mut left_or_right = None;
let mut section_start = 0;
for (i, line) in lines.iter().enumerate() {
match line {
diff::Result::Left(line) => {
if left_or_right == Some('R') {
write_lines(&mut result, &temp, section_start, '<');
temp.clear();
} else if left_or_right != Some('L') {
section_start = i;
}
temp.push(line);
left_or_right = Some('L');
}
diff::Result::Right(line) => {
if left_or_right == Some('L') {
write_lines(&mut result, &temp, section_start, '>');
temp.clear();
} else if left_or_right != Some('R') {
section_start = i;
}
temp.push(line);
left_or_right = Some('R');
}
diff::Result::Both { .. } => {
match left_or_right.take() {
Some('R') => write_lines(&mut result, &temp, section_start, '<'),
Some('L') => write_lines(&mut result, &temp, section_start, '>'),
_ => (),
}
temp.clear();
}
}
}
match left_or_right.take() {
Some('R') => write_lines(&mut result, &temp, section_start, '<'),
Some('L') => write_lines(&mut result, &temp, section_start, '>'),
_ => (),
}
result
}
fn test_data_dir() -> PathBuf {
let cwd = std::env::current_dir().expect("could not retrieve current directory");
let path = if cwd.ends_with("fea-rs") {
assert!(!cwd.parent().expect("always presnt").ends_with("fea-rs"));
"./test-data"
} else {
"./fea-rs/test-data"
};
let path = Path::new(path);
if !path.exists() {
panic!(
"could not locate fea-rs test-data. Please run from crate or project root. (cwd '{}')",
cwd.display()
);
}
path.to_owned()
}
pub(crate) fn fonttools_test_glyph_order() -> GlyphMap {
let mut path = test_data_dir();
path.push("simple_glyph_order.txt");
if !path.exists() {
panic!("could not locate glyph map at {}", path.display());
}
let order_str = std::fs::read_to_string(path).unwrap();
crate::compile::parse_glyph_order(&order_str)
.unwrap()
.iter()
.chain((800_u16..=1001).map(GlyphIdent::Cid))
.collect()
}
pub(crate) fn make_var_info() -> MockVariationInfo {
MockVariationInfo::new(&[("wght", 200, 200, 1000), ("wdth", 100, 100, 200)])
}
struct TestFeatureProvider;
impl FeatureProvider for TestFeatureProvider {
fn add_features(&self, builder: &mut crate::compile::FeatureBuilder) {
let mut kern = PairPosBuilder::default();
kern.insert_pair(
GlyphId16::new(20),
ValueRecordBuilder::new().with_x_advance(5),
GlyphId16::new(21),
ValueRecordBuilder::new(),
);
let kern_id = builder.add_lookup(PendingLookup::new(vec![kern], LookupFlag::empty(), None));
let mut mark = MarkToBaseBuilder::default();
mark.insert_mark(GlyphId16::new(116), "derp", AnchorBuilder::new(101, 102))
.unwrap();
mark.insert_base(GlyphId16::new(36), "derp", AnchorBuilder::new(11, 13));
let mark_id = builder.add_lookup(PendingLookup::new(
vec![mark],
LookupFlag::IGNORE_MARKS,
None,
));
builder.add_to_default_language_systems(Tag::new(b"kern"), &[kern_id]);
builder.add_to_default_language_systems(Tag::new(b"mark"), &[mark_id]);
}
}
impl Report {
pub fn has_failures(&self) -> bool {
self.results.iter().any(|r| !r.reason.is_success())
}
pub fn into_error(self) -> Result<(), Self> {
if self.has_failures() {
Err(self)
} else {
Ok(())
}
}
pub fn compare_printer<'a, 'b: 'a>(&'b self, old: &'a Report) -> impl std::fmt::Debug + 'a {
ReportComparePrinter { old, new: self }
}
fn widest_path(&self) -> usize {
self.results
.iter()
.map(|item| &item.path)
.map(|p| p.file_name().unwrap().to_str().unwrap().chars().count())
.max()
.unwrap_or(0)
}
fn summary(&self) -> ReportSummary {
let mut summary = ReportSummary::default();
for item in &self.results {
match &item.reason {
TestResult::Success => summary.passed += 1,
TestResult::Panic => summary.panic += 1,
TestResult::ParseFail(_) => summary.parse += 1,
TestResult::CompileFail(_) => summary.compile += 1,
TestResult::UnexpectedSuccess
| TestResult::TtxFail { .. }
| TestResult::ExpectedDiffFail { .. } => summary.other += 1,
TestResult::CompareFail { diff_percent, .. } => {
summary.compare += 1;
summary.sum_compare_perc += diff_percent;
}
}
}
summary
}
}
impl TestResult {
fn sort_order(&self) -> u8 {
match self {
Self::Success => 1,
Self::Panic => 2,
Self::ParseFail(_) => 3,
Self::CompileFail(_) => 4,
Self::UnexpectedSuccess => 6,
Self::TtxFail { .. } => 10,
Self::ExpectedDiffFail { .. } => 15,
Self::CompareFail { .. } => 50,
}
}
fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
pub fn printer(&self, verbose: bool) -> impl std::fmt::Display + '_ {
ReasonPrinter {
reason: self,
verbose,
}
}
}
impl std::fmt::Debug for ReportComparePrinter<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
debug_impl(f, self.new, Some(self.old), false)
}
}
struct OldResults<'a> {
map: Option<HashMap<&'a Path, TestResult>>,
}
impl<'a> OldResults<'a> {
fn new(report: Option<&'a Report>) -> Self {
Self {
map: report.map(|report| {
report
.results
.iter()
.map(|test| (test.path.as_path(), test.reason.clone()))
.collect()
}),
}
}
fn get(&self, result: &TestCase) -> ComparePrinter {
match self.map.as_ref() {
None => ComparePrinter::NotComparing,
Some(map) => match map.get(result.path.as_path()) {
None => ComparePrinter::Missing,
Some(prev) => match (prev, &result.reason) {
(
TestResult::CompareFail {
diff_percent: old, ..
},
TestResult::CompareFail {
diff_percent: new, ..
},
) => {
if (old - new).abs() > f64::EPSILON {
ComparePrinter::PercChange((new - old) * 100.)
} else {
ComparePrinter::Same
}
}
(x, y) if x == y => ComparePrinter::Same,
(old, _) => ComparePrinter::Different(old.clone()),
},
},
}
}
}
enum ComparePrinter {
NotComparing,
Missing,
Same,
PercChange(f64),
Different(TestResult),
}
impl std::fmt::Display for ComparePrinter {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ComparePrinter::NotComparing => Ok(()),
ComparePrinter::Missing => write!(f, "(new)"),
ComparePrinter::Same => write!(f, "--"),
ComparePrinter::PercChange(val) if val.is_sign_positive() => {
write!(f, "{}", Color::Green.paint(format!("+{val:.2}")))
}
ComparePrinter::PercChange(val) => {
write!(f, "{}", Color::Red.paint(format!("-{val:.2}")))
}
ComparePrinter::Different(reason) => write!(f, "{reason:?}"),
}
}
}
fn debug_impl(
f: &mut std::fmt::Formatter,
report: &Report,
old: Option<&Report>,
verbose: bool,
) -> std::fmt::Result {
writeln!(f, "failed test cases")?;
let path_pad = report.widest_path();
let old_results = OldResults::new(old);
for result in &report.results {
let old = old_results.get(result);
let file_name = result.path.file_name().unwrap().to_str().unwrap();
writeln!(
f,
"{file_name:path_pad$} {:<30} {old}",
result.reason.printer(verbose).to_string(),
)?;
}
let summary = report.summary();
let prefix = if old.is_some() { "new: " } else { "" };
writeln!(f, "{prefix}{summary}")?;
if let Some(old_summary) = old.map(Report::summary) {
writeln!(f, "old: {old_summary}")?;
}
if !verbose {
writeln!(f, "Set FEA_VERBOSE=1 for detailed output.")?;
}
Ok(())
}
impl std::fmt::Debug for Report {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let verbose = std::env::var(super::VERBOSE).is_ok();
debug_impl(f, self, None, verbose)
}
}
impl Display for ReasonPrinter<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.reason {
TestResult::Success => write!(f, "{}", Color::Green.paint("success")),
TestResult::Panic => write!(f, "{}", Color::Red.paint("panic")),
TestResult::ParseFail(diagnostics) => {
write!(f, "{}", Color::Purple.paint("parse failure"))?;
if self.verbose {
write!(f, "\n{diagnostics}")?;
}
Ok(())
}
TestResult::CompileFail(diagnostics) => {
write!(f, "{}", Color::Yellow.paint("compile failure"))?;
if self.verbose {
write!(f, "\n{diagnostics}")?;
}
Ok(())
}
TestResult::UnexpectedSuccess => {
write!(f, "{}", Color::Yellow.paint("unexpected success"))
}
TestResult::TtxFail { code, std_err } => {
write!(f, "ttx failure ({code:?}) stderr:\n{std_err}")
}
TestResult::ExpectedDiffFail { expected, result } => {
if self.verbose {
writeln!(f, "expected diff fail")?;
super::write_line_diff(f, expected, result)
} else {
write!(f, "{}", Color::Yellow.paint("expected diff fail"))
}
}
TestResult::CompareFail {
expected,
result,
diff_percent,
} => {
if self.verbose {
writeln!(f, "compare failure")?;
super::write_line_diff(f, expected, result)
} else {
write!(
f,
"{} ({:.0}%)",
Color::Blue.paint("compare failure"),
diff_percent * 100.0
)
}
}
}
}
}
impl Debug for TestResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.printer(std::env::var(super::VERBOSE).is_ok()).fmt(f)
}
}
impl ReportSummary {
fn total_items(&self) -> u32 {
self.passed + self.panic + self.parse + self.compile + self.compare + self.other
}
fn average_diff_percent(&self) -> f64 {
(self.sum_compare_perc + (self.passed as f64)) / self.total_items() as f64 * 100.
}
}
impl Display for ReportSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let total = self.total_items();
let perc = self.average_diff_percent();
let ReportSummary {
passed,
panic,
parse,
compile,
..
} = self;
write!(
f,
"passed {passed}/{total} tests: ({panic} panics {parse} unparsed {compile} compile) {perc:.2}% avg diff"
)
}
}