use std::{
fs::{self, File},
io,
path::{Path, PathBuf},
};
use image::{GenericImageView, ImageError};
use log::{debug, error};
use oicana::{CompileError, Template, TemplateInitializationError};
use oicana_export::png::{export_merged_png, EncodingError};
use oicana_files::native::{package_data_dir, NativeTemplate};
use oicana_input::{input::json::JsonInput, input_definition::InputDefinition, TemplateInputs};
use oicana_template::manifest::TemplateManifest;
use oicana_world::CompiledDocument;
use rand::thread_rng;
use thiserror::Error;
use crate::{Snapshot, SnapshotMode, Test};
pub struct TestRunnerContext {
packages: PathBuf,
}
impl TestRunnerContext {
pub fn new() -> Result<Self, CreateTestRunnerError> {
let packages = package_data_dir().ok_or(CreateTestRunnerError::NoPackageDirectory)?;
Ok(TestRunnerContext { packages })
}
pub fn get_runner(
&self,
path: &Path,
manifest: &TemplateManifest,
) -> Result<TestRunner, TemplateInitializationError> {
Ok(TestRunner {
instance: Template::<NativeTemplate>::from(path, &self.packages, manifest.clone())?,
template_path: path.to_path_buf(),
})
}
}
pub struct TestRunner {
instance: Template<NativeTemplate>,
template_path: PathBuf,
}
impl TestRunner {
pub fn reset(&mut self) {
self.instance.reset();
}
pub fn dependencies(&self) -> Vec<PathBuf> {
self.instance.dependencies()
}
pub fn run(&mut self, test: Test) -> Result<Vec<String>, TestExecutionError> {
if let Some(fuzzed_input) = test.fuzzed_inputs {
let fuzzed_json_input = self
.instance
.manifest()
.tool
.oicana
.inputs
.iter()
.filter_map(|input| {
let InputDefinition::Json(json) = input else {
return None;
};
Some(json)
})
.find(|input| input.key == fuzzed_input.key);
let Some(fuzzed_json_input) = fuzzed_json_input else {
return Err(TestExecutionError::FuzzingSetup(format!(
"Fuzzed input '{}' has no input definition in the manifest!",
fuzzed_input.key
)));
};
let Some(schema_path) = &fuzzed_json_input.schema else {
return Err(TestExecutionError::FuzzingSetup(format!(
"Fuzzed json input '{}' has no schema configured in the manifest!",
fuzzed_input.key
)));
};
let schema = {
let schema_file = match File::open(self.template_path.join(schema_path)) {
Ok(file) => file,
Err(error) => {
return Err(TestExecutionError::FuzzingSetup(format!(
"Failed to open schema file '{}': {}",
schema_path, error
)));
}
};
let schema = match serde_json::from_reader(schema_file) {
Ok(schema) => schema,
Err(error) => {
return Err(TestExecutionError::FuzzingSetup(format!(
"Failed to parse schema file '{}': {}",
schema_path, error
)));
}
};
match json_schema_ast::build_and_resolve_schema(&schema) {
Ok(schema) => schema,
Err(error) => {
return Err(TestExecutionError::FuzzingSetup(format!(
"Failed to build schema '{}': {}",
schema_path, error
)));
}
}
};
let mut rng = thread_rng();
return (0..fuzzed_input.samples)
.map(|_| {
let value = json_schema_fuzz::generate_value(&schema, &mut rng, u8::MAX);
let mut inputs = test.inputs.clone();
inputs.with_input(JsonInput::new(fuzzed_input.key.clone(), value.to_string()));
debug!("Fuzzing the input '{}' with {}", fuzzed_input.key, value);
self.run_with_inputs(inputs, &test.snapshot)
})
.map(|res| res.map(|vec| vec.into_iter()))
.collect::<Result<Vec<_>, _>>()
.map(|iterators| iterators.into_iter().flatten().collect());
}
self.run_with_inputs(test.inputs, &test.snapshot)
}
fn run_with_inputs(
&mut self,
inputs: TemplateInputs,
snapshot: &Snapshot,
) -> Result<Vec<String>, TestExecutionError> {
let CompiledDocument { document, warnings } = self.instance.compile(inputs)?;
let mut warnings = if let Some(warning) = warnings {
vec![warning]
} else {
vec![]
};
let image = export_merged_png(&document, 1.)?;
match snapshot {
Snapshot::Missing(path, mode) => match mode {
SnapshotMode::Update => {
warnings.push(format!(
"Writing snapshot file at {path:?}, because it was missing"
));
fs::write(path, image)?;
}
SnapshotMode::Compare => {
return Err(TestExecutionError::SnapshotMissing(path.clone()))
}
},
Snapshot::Some(path, mode) => {
if !compare_images(path, &image, 1)? {
let mut compare_path = path.clone();
if let Some(stem) = compare_path.file_stem() {
let mut new_name = stem.to_os_string();
match mode {
SnapshotMode::Compare => {
new_name.push(".compare.png");
}
SnapshotMode::Update => {
new_name.push(".png");
warnings.push(format!("Overwriting snapshot file at '{path:?}'."));
}
}
compare_path.set_file_name(new_name);
fs::write(compare_path, image)?;
} else {
error!("Snapshot file had no file stem!");
}
if matches!(mode, SnapshotMode::Compare) {
return Err(TestExecutionError::SnapshotMismatch(path.clone()));
}
}
}
Snapshot::None => (),
}
Ok(warnings)
}
}
pub fn compare_images(path: &Path, data: &[u8], tolerance: u8) -> Result<bool, ImageError> {
let img1 = image::open(path)?;
let img2 = image::load_from_memory(data)?;
if img1.dimensions() != img2.dimensions() {
return Ok(false);
}
let pixels1 = img1.pixels();
let pixels2 = img2.pixels();
for ((_, _, p1), (_, _, p2)) in pixels1.zip(pixels2) {
let diff =
p1.0.iter()
.zip(p2.0.iter())
.map(|(a, b)| (*a as i16 - *b as i16).unsigned_abs() as u8)
.max()
.unwrap_or(0);
if diff > tolerance {
return Ok(false);
}
}
Ok(true)
}
#[derive(Debug, Error)]
pub enum CreateTestRunnerError {
#[error("Failed to get Typst package directory.")]
NoPackageDirectory,
}
#[derive(Debug, Error)]
pub enum TestExecutionError {
#[error("{0}")]
CompileError(#[from] CompileError),
#[error("{0}")]
ExportError(#[from] EncodingError),
#[error("{0}")]
FuzzingSetup(String),
#[error("Failed to write or read snapshot image: {0}")]
Io(#[from] io::Error),
#[error("Failed to compare the snapshot: {0}")]
Comparison(#[from] ImageError),
#[error("The snapshot '{0}' does not match the result. Rerun with --update/-u to update it.")]
SnapshotMismatch(PathBuf),
#[error("The snapshot file '{0}' is missing. Rerun with --update/-u to create it.")]
SnapshotMissing(PathBuf),
}