diff --git a/crates/fayalite/src/prelude.rs b/crates/fayalite/src/prelude.rs index 42038ca..cebbddd 100644 --- a/crates/fayalite/src/prelude.rs +++ b/crates/fayalite/src/prelude.rs @@ -37,7 +37,7 @@ pub use crate::{ value::{SimOnly, SimOnlyValue, SimValue, ToSimValue, ToSimValueWithType}, }, source_location::SourceLocation, - testing::{FormalMode, assert_formal}, + testing::{FormalMode, assert_formal, checked_vcd_output}, ty::{AsMask, CanonicalType, TraceAsString, Type}, util::{ConstUsize, GenericConstUsize}, wire::Wire, diff --git a/crates/fayalite/src/testing.rs b/crates/fayalite/src/testing.rs index bc7a0b1..cb9db9c 100644 --- a/crates/fayalite/src/testing.rs +++ b/crates/fayalite/src/testing.rs @@ -12,11 +12,13 @@ use crate::{ bundle::BundleType, firrtl::ExportOptions, module::Module, - util::HashMap, + sim::{Simulation, vcd::VcdWriterDecls}, + util::{HashMap, RcWriter}, }; use serde::{Deserialize, Serialize}; use std::{ fmt::{self, Write}, + panic::Location, path::{Path, PathBuf}, process::Command, sync::{Mutex, OnceLock}, @@ -222,3 +224,190 @@ pub fn assert_formal>, T: BundleType>( ) .expect("testing::assert_formal() failed"); } + +pub struct CheckedVcdOutput { + writer: Option, + expected_path: PathBuf, + expected_contents: Result, std::io::Error)>, + location: &'static Location<'static>, +} + +impl CheckedVcdOutput { + #[must_use] + #[track_caller] + pub fn new(sim: &mut Simulation, expected_path: PathBuf) -> Self { + let writer = RcWriter::default(); + sim.add_trace_writer(VcdWriterDecls::new(writer.clone())); + Self { + writer: Some(writer), + expected_contents: std::fs::read_to_string(&expected_path).map_err(|e| { + eprintln!( + "error: failed to read expected VCD from: {}", + expected_path.display(), + ); + (std::env::current_dir().ok(), e) + }), + expected_path, + location: Location::caller(), + } + } + #[must_use] + #[track_caller] + #[doc(hidden)] + pub fn __checked_vcd_output_macro_helper( + sim: &mut Simulation, + cargo_manifest_dir: &'static str, + path: &'static str, + ) -> Self { + Self::new(sim, Path::new(cargo_manifest_dir).join(path)) + } + pub fn with_vcd_output(&self, f: impl FnOnce(&str) -> R) -> R { + let Some(writer) = &self.writer else { + unreachable!(); + }; + writer.clone().borrow(|output| { + let Ok(output) = str::from_utf8(output) else { + unreachable!("VcdWriter writes valid UTF-8"); + }; + f(output) + }) + } + #[track_caller] + pub fn finish(mut self) { + let Ok(()) = self.finish_impl(|msg| panic!("{msg}")); + } + fn finish_impl( + &mut self, + error: impl FnOnce(std::fmt::Arguments<'_>) -> E, + ) -> Result<(), E> { + let Self { + writer: Some(writer), + expected_path, + expected_contents, + location, + } = self + else { + // already finished + return Ok(()); + }; + let Ok(vcd) = String::from_utf8(writer.take()) else { + unreachable!("VcdWriter writes valid UTF-8"); + }; + let expected_path_d = expected_path.display(); + if expected_contents + .as_ref() + .is_ok_and(|expected_contents| *expected_contents == vcd) + { + // avoid written output from being split from threads interleaving writes to stdout + let _stdout = std::io::stderr().lock(); + // use println to get output captured by tests + println!("\n{location}: generated VCD matches the expected VCD in {expected_path_d}"); + return Ok(()); + } + // avoid written output from being split from threads interleaving writes to stderr + let _stderr = std::io::stderr().lock(); + let error = |msg: std::fmt::Arguments<'_>| { + // print msg at both beginning and end so it's easier to find when the vcd is huge + Err(error(format_args!( + "\n{msg}####### VCD:\n{vcd}\n#######\n{msg}" + ))) + }; + let error = |msg: std::fmt::Arguments<'_>| match &*expected_contents { + Ok(_) => error(format_args!( + "{location}: generated VCD doesn't match the expected VCD in {expected_path_d}\n\ + {msg}", + )), + Err((Some(current_dir), e)) => error(format_args!( + "{location}: generated VCD doesn't match the expected VCD in {expected_path_d}\n\ + error: failed to read: {e}\n\ + current dir: {current_dir}\n\ + {msg}", + current_dir = current_dir.display(), + )), + Err((None, e)) => error(format_args!( + "{location}: generated VCD doesn't match the expected VCD in {expected_path_d}\n\ + error: failed to read: {e}\n\ + {msg}", + )), + }; + const OVERWRITE_VAR_NAME: &str = "OVERWRITE_EXPECTED_VCD"; + const OVERWRITE_VAR_VALUE: &str = "overwrite"; + match std::env::var_os(OVERWRITE_VAR_NAME) { + Some(v) if v == OVERWRITE_VAR_VALUE => match std::fs::write(&expected_path, &vcd) { + Ok(()) => error(format_args!( + "warning: since `{OVERWRITE_VAR_NAME}={OVERWRITE_VAR_VALUE}` is set -- writing the generated VCD to {expected_path_d}\n" + )), + Err(e) => error(format_args!( + "error: since `{OVERWRITE_VAR_NAME}={OVERWRITE_VAR_VALUE}` is set -- tried to write the generated VCD to {expected_path_d}\n\ + error: failed to write: {e}" + )), + }, + _ => error(format_args!( + "note: rerun the test with the environment variable `{OVERWRITE_VAR_NAME}={OVERWRITE_VAR_VALUE}`\n\ + to update the expected output to match the generated output.\n" + )), + } + } +} + +impl Drop for CheckedVcdOutput { + #[track_caller] + fn drop(&mut self) { + let _ = self.finish_impl(|msg| { + if std::thread::panicking() { + eprintln!("{msg}"); // use eprintln to get output captured by tests + } else { + panic!("{msg}"); + } + }); + } +} + +#[macro_export] +/// Use in tests to check that [`Simulation`] generates the expected VCD traces, by comparing to a `.vcd` file containing the expected traces. +/// +/// Use like so: +/// ``` +/// # use fayalite::prelude::*; +/// # +/// # #[hdl_module] +/// # fn my_module() { +/// # #[hdl] +/// # let a: UInt<8> = m.input(); +/// # #[hdl] +/// # let b: UInt<8> = m.output(); +/// # connect(b, 0u8); +/// # #[hdl] +/// # if a.cmp_eq(100u8) { +/// # connect(b, 42u8); +/// # } +/// # } +/// // inside your #[test] fn my_test(): +/// +/// // get the module to simulate: +/// let m = my_module(); +/// // create a simulation of the module: +/// let mut sim = Simulation::new(m); +/// // set up the expected VCD traces, the given .vcd path is relative to env!("CARGO_MANIFEST_DIR") +/// let _checked_vcd_output = checked_vcd_output!( +/// &mut sim, +/// "tests/expected/my_test.vcd", +/// ); +/// // now run the simulation like normal: +/// sim.write(sim.io().a, 0u8); +/// assert_eq!(sim.read(sim.io().b).as_int(), 0); +/// sim.advance_time(SimDuration::from_micros(1)); +/// sim.write(sim.io().a, 100u8); +/// assert_eq!(sim.read(sim.io().b).as_int(), 42); +/// ``` +macro_rules! checked_vcd_output { + ($sim:expr, $path_relative_to_manifest_dir:expr $(,)?) => { + $crate::testing::CheckedVcdOutput::__checked_vcd_output_macro_helper( + $sim, + $crate::__std::env!("CARGO_MANIFEST_DIR"), + $crate::__std::concat!($path_relative_to_manifest_dir), + ) + }; +} + +pub use checked_vcd_output; diff --git a/crates/fayalite/tests/expected/my_test.md b/crates/fayalite/tests/expected/my_test.md new file mode 100644 index 0000000..a3b52ae --- /dev/null +++ b/crates/fayalite/tests/expected/my_test.md @@ -0,0 +1,6 @@ + + +`my_test.vcd` is used in the doctest of `fayalite::testing::checked_vcd_output` diff --git a/crates/fayalite/tests/expected/my_test.vcd b/crates/fayalite/tests/expected/my_test.vcd new file mode 100644 index 0000000..3df1caf --- /dev/null +++ b/crates/fayalite/tests/expected/my_test.vcd @@ -0,0 +1,13 @@ +$timescale 1 ps $end +$scope module my_module $end +$var wire 8 gAF7X a $end +$var wire 8 QS=a/ b $end +$upscope $end +$enddefinitions $end +$dumpvars +b0 gAF7X +b0 QS=a/ +$end +#1000000 +b1100100 gAF7X +b101010 QS=a/ diff --git a/scripts/check-copyright.sh b/scripts/check-copyright.sh index 99205bb..779bcbf 100755 --- a/scripts/check-copyright.sh +++ b/scripts/check-copyright.sh @@ -47,7 +47,7 @@ function main() */LICENSE.md|*/Notices.txt) # copyright file ;; - /crates/fayalite/tests/ui/*.stderr|/crates/fayalite/tests/sim/expected/*.vcd|/crates/fayalite/tests/sim/expected/*.txt) + /crates/fayalite/tests/ui/*.stderr|/crates/fayalite/tests/expected/*.vcd|/crates/fayalite/tests/sim/expected/*.vcd|/crates/fayalite/tests/sim/expected/*.txt) # file that can't contain copyright header ;; /.forgejo/workflows/*.yml|*/.gitignore|*.toml|*/Makefile|*/_CoqProject)