texlang_stdlib/
testing.rs

1//! Utilities for writing unit tests
2//!
3//! This module contains utilities (types, helper functions and a Rust macro)
4//!     that make it easier to write unit tests for Texlang primitives.
5//! It's based on the philosophy that high-equality extensive unit tests
6//!     will be written if and only if writing them is easy.
7//!
8//! In general the main tool used in this module is the [test_suite] Rust macro,
9//!     which generates a suite of unit tests for a set of primitives.
10
11use std::collections::HashMap;
12
13use crate::prefix;
14use crate::script;
15use texlang::traits::*;
16use texlang::vm::implement_has_component;
17use texlang::vm::VM;
18use texlang::*;
19
20/// Simple state type for use in unit tests.
21///
22/// If the primitives under test don't require custom components or
23/// other pieces in the state, it is easier to use this type rather than defining a custom one.
24#[derive(Default)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct State {
27    // TODO: Consider whether prefix should be here.
28    // Getting and setting global should be available via the VMs scope handler
29    // The other prefixes are \def specific, and the \def tests can be change to handle this.
30    prefix: prefix::Component,
31    script: script::Component,
32    integer: i32,
33}
34
35impl TexlangState for State {}
36
37implement_has_component![
38    State,
39    (prefix::Component, prefix),
40    (script::Component, script),
41];
42
43impl State {
44    pub fn get_integer() -> command::BuiltIn<State> {
45        variable::Command::new_singleton(
46            |state: &State, _: variable::Index| -> &i32 { &state.integer },
47            |state: &mut State, _: variable::Index| -> &mut i32 { &mut state.integer },
48        )
49        .into()
50    }
51}
52
53/// Option passed to a test runner.
54pub enum TestOption<'a, S> {
55    /// The initial commands are the result of invoking the provided static function.
56    ///
57    /// Overrides previous `InitialCommands` or `InitialCommandsDyn` options.
58    InitialCommands(fn() -> HashMap<&'static str, command::BuiltIn<S>>),
59
60    /// The initial commands are the result of invoking the provided closure.
61    ///
62    /// Overrides previous `InitialCommands` or `InitialCommandsDyn` options.
63    InitialCommandsDyn(Box<dyn Fn() -> HashMap<&'static str, command::BuiltIn<S>> + 'a>),
64
65    /// The provided static function is invoked after the VM is created and before execution starts.
66    /// This can be used to provide more custom VM initialization.
67    ///
68    /// Overrides previous `CustomVMInitialization` or `CustomVMInitializationDyn` options.
69    CustomVMInitialization(fn(&mut VM<S>)),
70
71    /// The provided closure is invoked after the VM is created and before execution starts.
72    /// This can be used to provide more custom VM initialization.
73    ///
74    /// Overrides previous `CustomVMInitialization` or `CustomVMInitializationDyn` options.
75    #[allow(clippy::type_complexity)]
76    CustomVMInitializationDyn(Box<dyn Fn(&mut VM<S>) + 'a>),
77
78    /// Whether undefined commands raise an error.
79    ///
80    /// Overrides previous `AllowUndefinedCommands` options.
81    AllowUndefinedCommands(bool),
82}
83
84/// Run an expansion equality test.
85///
86/// The test passes if the two provided input strings expand to the same tokens.
87pub fn run_expansion_equality_test<S>(lhs: &str, rhs: &str, options: &[TestOption<S>])
88where
89    S: Default + HasComponent<script::Component>,
90{
91    let options = ResolvedOptions::new(options);
92
93    let mut vm_1 = initialize_vm(&options);
94    let output_1 = crate::testing::execute_source_code(&mut vm_1, lhs, &options).unwrap();
95
96    let mut vm_2 = initialize_vm(&options);
97    let output_2 = crate::testing::execute_source_code(&mut vm_2, rhs, &options).unwrap();
98    compare_output(output_1, &vm_1, output_2, &vm_2)
99}
100
101fn compare_output<S>(
102    output_1: Vec<token::Token>,
103    vm_1: &vm::VM<S>,
104    output_2: Vec<token::Token>,
105    vm_2: &vm::VM<S>,
106) {
107    use ::texlang::token::Value::ControlSequence;
108    println!("{output_1:?}");
109    let equal = match output_1.len() == output_2.len() {
110        false => {
111            println!(
112                "output lengths do not match: {} != {}",
113                output_1.len(),
114                output_2.len()
115            );
116            false
117        }
118        true => {
119            let mut equal = true;
120            for (token_1, token_2) in output_1.iter().zip(output_2.iter()) {
121                let token_equal = match (&token_1.value(), &token_2.value()) {
122                    (ControlSequence(cs_name_1), ControlSequence(cs_name_2)) => {
123                        let name_1 = vm_1.cs_name_interner().resolve(*cs_name_1).unwrap();
124                        let name_2 = vm_2.cs_name_interner().resolve(*cs_name_2).unwrap();
125                        name_1 == name_2
126                    }
127                    _ => token_1 == token_2,
128                };
129                if !token_equal {
130                    equal = false;
131                    break;
132                }
133            }
134            equal
135        }
136    };
137
138    if !equal {
139        println!("Expansion output is different:");
140        println!("------[lhs]------");
141        println!(
142            "'{}'",
143            ::texlang::token::write_tokens(&output_1, vm_1.cs_name_interner())
144        );
145        println!("------[rhs]------");
146        println!(
147            "'{}'",
148            ::texlang::token::write_tokens(&output_2, vm_2.cs_name_interner())
149        );
150        println!("-----------------");
151        panic!("Expansion test failed");
152    }
153}
154
155/// Run a failure test.
156///
157/// The test passes if execution of the provided input fails.
158pub fn run_failure_test<S>(input: &str, options: &[TestOption<S>])
159where
160    S: Default + HasComponent<script::Component>,
161{
162    let options = ResolvedOptions::new(options);
163
164    let mut vm = initialize_vm(&options);
165    let result = execute_source_code(&mut vm, input, &options);
166    if let Ok(output) = result {
167        println!("Expansion succeeded:");
168        println!(
169            "{}",
170            ::texlang::token::write_tokens(&output, vm.cs_name_interner())
171        );
172        panic!("Expansion failure test did not pass: expansion successful");
173    }
174}
175
176pub enum SerdeFormat {
177    Json,
178    MessagePack,
179}
180
181/// Skip a serialization/deserialization test if the serde feature is off
182#[cfg(not(feature = "serde"))]
183pub fn run_serde_test<S>(_: &str, _: &str, _: &[TestOption<S>], format: SerdeFormat) {}
184
185/// Run a serialization/deserialization test
186#[cfg(feature = "serde")]
187pub fn run_serde_test<S>(
188    input_1: &str,
189    input_2: &str,
190    options: &[TestOption<S>],
191    format: SerdeFormat,
192) where
193    S: Default + HasComponent<script::Component> + serde::Serialize + serde::de::DeserializeOwned,
194{
195    let options = ResolvedOptions::new(options);
196
197    let mut vm_1 = initialize_vm(&options);
198    let mut output_1_1 = crate::testing::execute_source_code(&mut vm_1, input_1, &options).unwrap();
199
200    let mut vm_1 = match format {
201        SerdeFormat::Json => {
202            let serialized = serde_json::to_string_pretty(&vm_1).unwrap();
203            println!("Serialized VM: {serialized}");
204            let mut deserializer = serde_json::Deserializer::from_str(&serialized);
205            vm::VM::deserialize(&mut deserializer, (options.initial_commands)())
206        }
207        SerdeFormat::MessagePack => {
208            let serialized = rmp_serde::to_vec(&vm_1).unwrap();
209            let mut deserializer = rmp_serde::decode::Deserializer::from_read_ref(&serialized);
210            vm::VM::deserialize(&mut deserializer, (options.initial_commands)())
211        }
212    };
213
214    vm_1.push_source("testing2.tex", input_2).unwrap();
215    let mut output_1_2 = script::run(&mut vm_1).unwrap();
216    output_1_1.append(&mut output_1_2);
217
218    let mut vm_2 = initialize_vm(&options);
219    let output_2 =
220        crate::testing::execute_source_code(&mut vm_2, format!["{input_1}{input_2}"], &options)
221            .unwrap();
222
223    compare_output(output_1_1, &vm_1, output_2, &vm_2)
224}
225
226pub struct ResolvedOptions<'a, S> {
227    initial_commands: &'a dyn Fn() -> HashMap<&'static str, command::BuiltIn<S>>,
228    custom_vm_initialization: &'a dyn Fn(&mut VM<S>),
229    allow_undefined_commands: bool,
230}
231
232impl<'a, S> ResolvedOptions<'a, S> {
233    pub fn new(options: &'a [TestOption<S>]) -> Self {
234        let mut resolved = Self {
235            initial_commands: &HashMap::new,
236            custom_vm_initialization: &|_| {},
237            allow_undefined_commands: true,
238        };
239        for option in options {
240            match option {
241                TestOption::InitialCommands(f) => resolved.initial_commands = f,
242                TestOption::InitialCommandsDyn(f) => resolved.initial_commands = f,
243                TestOption::CustomVMInitialization(f) => resolved.custom_vm_initialization = f,
244                TestOption::CustomVMInitializationDyn(f) => resolved.custom_vm_initialization = f,
245                TestOption::AllowUndefinedCommands(b) => resolved.allow_undefined_commands = *b,
246            }
247        }
248        resolved
249    }
250}
251
252pub fn initialize_vm<S: Default>(options: &ResolvedOptions<S>) -> Box<vm::VM<S>> {
253    let mut vm = VM::<S>::new((options.initial_commands)());
254    (options.custom_vm_initialization)(&mut vm);
255    vm
256}
257
258/// Execute source code in a VM with the provided options.
259pub fn execute_source_code<S, T: Into<String>>(
260    vm: &mut vm::VM<S>,
261    source: T,
262    options: &ResolvedOptions<S>,
263) -> Result<Vec<token::Token>, Box<error::Error>>
264where
265    S: Default + HasComponent<script::Component>,
266{
267    vm.push_source("testing.tex", source).unwrap();
268    script::set_allow_undefined_command(&mut vm.state, options.allow_undefined_commands);
269    script::run(vm)
270}
271
272/// In-memory filesystem for use in unit tests.
273///
274/// This type mocks out the file system operations in the VM.
275/// It provides an in-memory system to which "files" can be added before the test runs.
276/// It is designed to help test primitives that interact with the filesystem.
277///
278/// Given a VM, the file system can be set as follows:
279/// ```
280/// # type State = ();
281/// # use texlang::vm;
282/// # use texlang_stdlib::testing::*;
283/// let mut vm = vm::VM::<State>::new(
284///     Default::default(),  // initial commands
285/// );
286/// let mock_file_system: InMemoryFileSystem = Default::default();
287/// vm.file_system = Box::new(mock_file_system);
288/// ```
289///
290/// When using the test runners in this module or the [test_suite] macro,
291///     assign the file system ops using the
292///     [TestOption::CustomVMInitializationDyn] option:
293/// ```
294/// # type State = ();
295/// # use texlang::vm;
296/// # use texlang_stdlib::testing::*;
297/// let options = TestOption::CustomVMInitializationDyn(Box::new(|vm: &mut vm::VM<State>|{
298///     let mock_file_system: InMemoryFileSystem = Default::default();
299///     vm.file_system = Box::new(mock_file_system);
300/// }));
301/// ```
302#[derive(Default)]
303pub struct InMemoryFileSystem {
304    files: HashMap<std::path::PathBuf, String>,
305}
306
307impl vm::FileSystem for InMemoryFileSystem {
308    fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String> {
309        match self.files.get(path) {
310            None => Err(std::io::Error::new(
311                std::io::ErrorKind::NotFound,
312                "not found",
313            )),
314            Some(content) => Ok(content.clone()),
315        }
316    }
317    fn write_bytes(&self, _: &std::path::Path, _: &[u8]) -> std::io::Result<()> {
318        unimplemented!()
319    }
320}
321
322impl InMemoryFileSystem {
323    /// Add a file to the in-memory file system.
324    pub fn add_file(&mut self, path: std::path::PathBuf, content: &str) {
325        self.files.insert(path, content.to_string());
326    }
327}
328
329/// Macro to generate a suite of unit tests
330///
331/// The general use of this macros looks like this:
332/// ```
333/// # use texlang_stdlib::testing::*;
334/// test_suite![
335///     state(State),
336///     options(TestOptions::InitialCommands(initial_commands)),
337///     expansion_equality_tests(
338///         (case_1, "lhs_1", "rhs_1"),
339///         (case_2, "lhs_2", "rhs_2"),
340///     ),
341///     failure_tests(
342///         (case_3, "input_3"),
343///         (case_4, "input_4"),
344///     ),
345/// ];
346/// ```
347///
348/// The arguments to the macro are:
349///
350/// - `state(State)`: defines which Rust type to use as the VM state in the tests.
351///     This can be omitted, in which case it defaults to the type name `State` in the current scope.
352///
353/// - `options(option_1, option_2, ..., option_n)`: options to pass to the test runner.
354///     This is a list of values of type [TestOption].
355///     The options can be omitted, in which case they default to `options(TestOptions::InitialCommands(initial_commands))`.
356///     In this case `initial_commands` is a static function that returns a list of built-in primitives
357///     to initialize the VM with.
358///
359/// - `expansion_equality_tests(cases...)`: a list of expansion equality test cases.
360///     Each case is of the form (case name, left hand side, right hand side).
361///     The data here is fed into the [run_expansion_equality_test] test runner.
362///     
363/// - `failure_tests(cases...)`: a list of failure test cases.
364///     Each case is of the form (case name, input).
365///     The data here is fed into the [run_failure_test] test runner.
366///
367/// Only one `state()` argument may be provided, and if provided it must be in the first position.
368/// Only one `options()` argument may be provided, and if provided it must be in the first position
369///     or after the `state()` argument.
370/// Zero or more of the other arguments may be provided, and in any order.
371#[macro_export]
372macro_rules! test_suite {
373    ( state($state: ty), options $options: tt, expansion_equality_tests ( $( ($name: ident, $lhs: expr, $rhs: expr $(,)? ) ),* $(,)? ) $(,)? ) => (
374        $(
375            #[test]
376            fn $name() {
377                let lhs = $lhs;
378                let rhs = $rhs;
379                let options = vec! $options;
380                $crate::testing::run_expansion_equality_test::<$state>(&lhs, &rhs, &options);
381            }
382        )*
383    );
384    ( state($state: ty), options $options: tt, expansion_equality_tests $test_body: tt $(,)? ) => (
385        compile_error!("Invalid test cases for expansion_equality_tests: must be a list of tuples (name, lhs, rhs)");
386    );
387    ( state($state: ty), options $options: tt, serde_tests ( $( ($name: ident, $lhs: expr, $rhs: expr $(,)? ) ),* $(,)? ) $(,)? ) => (
388        $(
389            mod $name {
390                use super::*;
391                #[cfg_attr(not(feature = "serde"), ignore)]
392                #[test]
393                fn json() {
394                    let lhs = $lhs;
395                    let rhs = $rhs;
396                    let options = vec! $options;
397                    $crate::testing::run_serde_test::<$state>(&lhs, &rhs, &options, $crate::testing::SerdeFormat::Json);
398                }
399                #[cfg_attr(not(feature = "serde"), ignore)]
400                #[test]
401                fn message_pack() {
402                    let lhs = $lhs;
403                    let rhs = $rhs;
404                    let options = vec! $options;
405                    $crate::testing::run_serde_test::<$state>(&lhs, &rhs, &options, $crate::testing::SerdeFormat::MessagePack);
406                }
407            }
408        )*
409    );
410    ( state($state: ty), options $options: tt, failure_tests ( $( ($name: ident, $input: expr $(,)? ) ),* $(,)? ) $(,)? ) => (
411        $(
412            #[test]
413            fn $name() {
414                let input = $input;
415                let options = vec! $options;
416                $crate::testing::run_failure_test::<$state>(&input, &options);
417            }
418        )*
419    );
420    ( state($state: ty), options $options: tt, $test_kind: ident $test_cases: tt $(,)? ) => (
421        compile_error!("Invalid keyword: test_suite! only accepts the following keywords: `state, `options`, `expansion_equality_tests`, `failure_tests`, `serde_tests`");
422    );
423    ( state($state: ty), options $options: tt, $( $test_kind: ident $test_cases: tt ),+ $(,)? ) => (
424        $(
425            test_suite![state($state), options $options, $test_kind $test_cases,];
426        )+
427    );
428    ( options $options: tt, $( $test_kind: ident $test_cases: tt ),+ $(,)? ) => (
429        test_suite![state(State), options $options, $( $test_kind $test_cases, )+ ];
430    );
431    ( $( $test_kind: ident $test_cases: tt ),+ $(,)? ) => (
432        test_suite![options (TestOption::InitialCommands(initial_commands)), $( $test_kind $test_cases, )+ ];
433    );
434}
435
436pub use test_suite;