diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000000..a3e602f431 --- /dev/null +++ b/cpanfile @@ -0,0 +1,32 @@ +# install required module dependancies listed here (runtime and test) with +# cpanm --installdeps . + +on runtime => sub { + requires 'perl' => '5.20.3'; # guessing at a minimum viable version + recommends 'perl' => '5.30.0'; # needed for Statistics::R::IO + + requires 'DBI'; + requires 'Digest::MD5'; + requires 'HTML::Entities'; + requires 'JSON'; + requires 'Tie::IxHash'; + requires 'URI::Escape'; + requires 'UUID::Tiny'; +}; + +on test => sub { + requires 'HTML::TagParser'; # for t/macros/basicmacros.t + requires 'Test2::V0'; # for t/units/* + + recommends 'Data::Dumper'; # for debugging data structures + recommends 'Test::Number::Delta'; # future unit tests using tolerance + recommends 'Test2::Tools::Explain'; # for debugging data structures +}; + +# install author dependancies with +# cpanm --installdeps --with-develop --with-recommends . + +on develop => sub { + recommends 'Module::CPANfile'; + recommends 'Test::CPANfile'; # verifies this file has all the dependancies +}; diff --git a/lib/Units.pm b/lib/Units.pm index 50830b868f..a2dc07505f 100644 --- a/lib/Units.pm +++ b/lib/Units.pm @@ -475,9 +475,12 @@ our %known_units = ('m' => { # cal -- calorie # kcal -- kilocalorie # eV -- electron volt +# keV -- kilo electron volt +# MeV -- mega electron volt +# GeV -- giga electron volt # kWh -- kilo Watt hour # - 'J' => { + 'J' => { 'factor' => 1, 'm' => 2, 'kg' => 1, @@ -501,13 +504,13 @@ our %known_units = ('m' => { 'kg' => 1, 's' => -2 }, - 'kt' => { + 'kt' => { 'factor' => 4.184E12, 'm' => 2, 'kg' => 1, 's' => -2 }, - 'Mt' => { + 'Mt' => { 'factor' => 4.184E15, 'm' => 2, 'kg' => 1, @@ -525,8 +528,26 @@ our %known_units = ('m' => { 'kg' => 1, 's' => -2 }, - 'eV' => { - 'factor' => 1.60E-9, + 'eV' => { + 'factor' => 1.6022E-19, + 'm' => 2, + 'kg' => 1, + 's' => -2 + }, + 'keV' => { + 'factor' => 1.6022E-16, + 'm' => 2, + 'kg' => 1, + 's' => -2 + }, + 'MeV' => { + 'factor' => 1.6022E-13, + 'm' => 2, + 'kg' => 1, + 's' => -2 + }, + 'GeV' => { + 'factor' => 1.6022E-10, 'm' => 2, 'kg' => 1, 's' => -2 diff --git a/t/README b/t/README deleted file mode 100644 index 77ca89b829..0000000000 --- a/t/README +++ /dev/null @@ -1,14 +0,0 @@ -These files, contributed by Boyd Duffe, are an example of writing unit tests -for PG modules so that we can determine (a) that they work, and (b) that they continue -to work when some seemingly innocuous change elsewhere in the system causes troubles -in this module. - -Of course this is not supposed to happen when modular programming practices are followed -but it happens anyway. :-) - -We need more "unit tests" like this. (The unit does not refer to Unit.pm but to testing -a small piece or unit of the WeBWorK/PG code.) I would also like to wire these -in so that administrators can perform these tests from the admin page on the web -to reassure themselves that the new version of PG they are using is functioning correctly. - - diff --git a/t/README.md b/t/README.md index 93f8ef8c8b..29f3053262 100644 --- a/t/README.md +++ b/t/README.md @@ -1,14 +1,45 @@ -# Unit Tests for PG +# Testing PG + +This directory houses the resources for testing PG. It includes a mix +of strategies for testing at different scales. It helps to catch errors +before they are found in production and prevent regressions from being +re-introduced. + +The philosophy of +[Test Driven Design](https://en.wikipedia.org/wiki/Test-driven_development) +is that when a bug is found, a test is written to show it failing +and when it is fixed, the test will pass. +The unit tests are easy to run and amenable to automation. Some services +can be "mocked" so that behaviour can be tested in their absence. +All of this is to provide confidence that the code does what is intended +and a working test can be better than documentation because it shows how +the code currently works in practice. + +Old references can be found on the WebWork wiki page +[Unit Testing](https://webwork.maa.org/wiki/Unit_Testing) + + +# Unit Tests + +[Unit tests](https://en.wikipedia.org/wiki/Unit_testing) look at small chunks +of self-coherent code to verify the behaviour of a subroutine or module. +This is the test you write to catch corner cases or to explore code branches. +In this repository, all files with the `.t` extension are unit tests which +are found by Perl's [prove](https://perldoc.perl.org/prove) command. The individual unit tests are located in each of the directories. +Best practice is to create a directory for each module being tested and +group similar tests together in separate files with a descriptive name, +such as **t/units/** for testing the **Units.pm** module. -Formal unit tests are located in the the `macros` and `contexts` directories that are designed to test the pg macros and contexts respectively. +Formal unit tests are located in the the `macros` and `contexts` directories +that are designed to test the pg macros and contexts respectively. ## Running the tests ```bash cd $PG_ROOT -prove -r . +prove -lr t/ ``` will run all of the tests in `.t` files within subdirectories of `t`. @@ -23,6 +54,7 @@ prove -v pgaux.t ``` which will be verbose (`-v`). +Or you could use `prove -lv t/macros/pgaux.t` from the root directory. ## Writing a Unit Test @@ -70,3 +102,34 @@ is(check_score($f->eval(x=>2),"4"),1,"math objects: eval x^2 at x=2"); ``` The `check_score` subroutine evaluates and compares a MathObject with a string representation of the answer. If the score is 1, then the two are equal. + + +# Integration tests + +[Integration testing](https://en.wikipedia.org/wiki/Integration_testing) +tests components working together as a group. The files with the `.pg` +extension are used to demonstrate the output of the rendering engine. + +**TODO:** add an explanation of how to run these integration tests +and their requirements. + + +# Test Dependencies + +The tests for **Units.pm** have brought in a new module dependency, +[Test2](https://metacpan.org/pod/Test2::V0) which is the state of the art in +testing Perl modules. It can compare data structures, examine warnings and +catch fatal errors thrown under expected conditions. It provides many tools +for testing and randomly executes its subtests to avoid the programmer +depending on stateful data. + +To make these easier to install with +[cpanm](https://metacpan.org/dist/App-cpanminus/view/bin/cpanm), there is a +[cpanfile](https://metacpan.org/dist/Module-CPANfile/view/lib/cpanfile.pod) +in the root directory. Use + + cpanm --installdeps . + +which will install the runtime and test dependencies. +To use the cpanfile for a minimal install skipping the test requirements, +use the `--notest` option with cpanm. diff --git a/t/contexts/trig_degrees.t b/t/contexts/trig_degrees.t index 3d2230ad75..2a051ec33b 100644 --- a/t/contexts/trig_degrees.t +++ b/t/contexts/trig_degrees.t @@ -19,6 +19,12 @@ require("$main::pg_dir/t/build_PG_envir.pl"); ## END OF TOP_MATERIAL +# remove warnings about redefining trig functions +delete $main::{sin}; +delete $main::{cos}; +delete $main::{atan2}; + + loadMacros("contextTrigDegrees.pl"); my $ctx = Context("TrigDegrees"); diff --git a/t/lib/Test/PG.pm b/t/lib/Test/PG.pm new file mode 100644 index 0000000000..c382a303b8 --- /dev/null +++ b/t/lib/Test/PG.pm @@ -0,0 +1,108 @@ +package Test::PG; + +BEGIN { + die "PG_ROOT not found in environment.\n" unless $ENV{PG_ROOT}; + $main::pg_dir = $ENV{PG_ROOT}; +} + +use warnings; +use strict; + +use lib "$main::pg_dir/lib"; # only needed if not using prove with -l + + +=head1 Test::PG + +This module provides the environment and some generic functions for +writing tests for PG macros. Mostly copied from F, +it also redefines Test2's "exists" function from C to C +to avoid the conflict with WebWork's exponential function. +Its final action is to load C. + +The reason for the module is that There Is More Than One Way To Do It +and people develop different styles. This is my coding style. +Also we learn the underlying structures better by re-inventing the wheel. +We just try to make a better wheel, but success is not guaranteed. + +This module strives for elegance in reducing boiler plate in test files, +a minimum of duplicated code and adheres to the principle of least surprise +by locating modules below the C directory. +It does not make a decent cup of tea. + +=head2 Usage + +To test a macro that needs to be loaded, include this preamble +at the top of your test file and customize. + + use Test2::V0; + + use MyPGLib; # does your macro need a module? + + use lib 't/lib'; + use Test::PG; # setup a minimal WW environment + + loadMacros("parserMyPGMacro.pl"); + + Context("Numeric"); + +And run your test from the $PG_ROOT directory with + + prove -l t/my_macro/my_test.t + + +=head2 TODO + +=head3 Quiet warnings in F + +The functions sin, cos and atan2 are redefined and generate warnings. +C them from the symbol table before loading the macro. + +F declares C<$deg> twice, it being +a required file, the package scope isn't heeded by the second C. +I wonder if this happens every time this macro is loaded. + +=cut + + +package main; + +$main::{EXISTS} = delete $main::{E}; # redefine Test2's E() function as EXISTS() + +my $macros_dir = "$main::pg_dir/macros"; + +# use WeBWorK::Localize; +use PGcore; +use Parser; + +# build up enough of a PG environment to get things running +our %envir = ( + htmlDirectory => '/opt/webwork/courses/daemon_course/html', + htmlURL => 'http://localhost/webwork2/daemon_course/html', + tempURL => 'http://localhost/webwork2/daemon_course/tmp', + pgDirectories => { macrosPath => ["$macros_dir"] }, + macrosPath => ["$macros_dir"], + displayMode => 'HTML_MathJax', + language => 'en-us', + language_subroutine => sub { return @_; }, # return the string passed in instead going to maketext +); + +sub be_strict { + require 'ww_strict.pm'; + strict::import(); +} + +sub PG_restricted_eval { + WeBWorK::PG::Translator::PG_restricted_eval(@_); +} + +sub check_score { + my ($correct_answer, $ans) = @_; + return $correct_answer->cmp->evaluate($ans)->{score}; +} + +require "$macros_dir/PG.pl"; +DOCUMENT(); + +loadMacros('PGbasicmacros.pl'); + +1; diff --git a/t/units/basic_module.t b/t/units/basic_module.t new file mode 100644 index 0000000000..0ad70fe095 --- /dev/null +++ b/t/units/basic_module.t @@ -0,0 +1,24 @@ +use Test2::V0; + +use Units; + +# get unit hashes +my %joule = evaluate_units('J'); +my %newton_metre = evaluate_units('N*m'); +my %energy_base_units = evaluate_units('kg*m^2/s^2'); + +# basic definitions of energy equivalence +is \%joule, \%newton_metre, + 'A joule is a newton-metre'; +is \%joule, \%energy_base_units, + 'A joule is a kg metre squared per second squared'; + + +# test the error handling +my $fake = 'bleurg'; +ok my %error = evaluate_units($fake); +like $error{ERROR}, qr/UNIT ERROR Unrecognizable unit: \|$fake\|/, + "No unit '$fake' defined in Units file"; + + +done_testing; diff --git a/t/units/basic_parser.t b/t/units/basic_parser.t new file mode 100644 index 0000000000..8fb3bc661a --- /dev/null +++ b/t/units/basic_parser.t @@ -0,0 +1,202 @@ +use Test2::V0; + +use Parser::Legacy::NumberWithUnits; # load this before the parser macro +use Units; + +use lib 't/lib'; +use Test::PG; + +loadMacros("parserNumberWithUnits.pl"); + +Context("Numeric"); + + +=head1 NumberWithUnits + +We test the basic functionality of the NumberWithUnits parser, +F, to give us faith +that the parser and its methods are working. +Other test files will probe deeper into specific use cases of +the NumberWithUnits macro and the L module. + +=head2 Testing Strategy + +Test all the methods of an object, check the attributes, verify the errors +are thrown correctly, display strings look the way they should and +all the ways that a student could answer produce the appropriate results. +Check that the objects we create belong to their expected class. + +Demonstrate some of the flavour of Test2, with hash, bag, dies, todo, etc. +Group similar tests into subtests. + +=head3 Setup + +All the boilerplate code is loaded with Test::PG and assume that people run +it from the root directory with C. +Load your base modules before loading the macros which depend on them +and set the Context, if appropriate. + +See the example in the documentation of L + + perldoc t/lib/Test/PG.pm + +=head2 TODO list + +=over 4 + +=item Fix display of temperature units + +=item Test adding new units + +=item Look up how to get the value of the object instead of reaching into the hashref + +=item Test messages from wrong student answer submissions + +=back + +=cut + + +# define some basic objects +ok my $joule = NumberWithUnits(1, 'J'); +ok my $Nm = NumberWithUnits(1, 'N*m'); +ok my $energy_base_units = NumberWithUnits(1, 'kg*m^2/s^2'); + + +subtest 'Verify classes and methods' => sub { + isa_ok $joule, 'Parser::Legacy::NumberWithUnits'; + can_ok $joule, + [ qw/cmp splitUnits getUnitNames getUnits TeXunits cmp_parse adjustCorrectValue + add_fundamental_unit add_unit string TeX / ], + 'Can we NumberWithUnits'; + + ok my $evaluator = $joule->cmp($Nm), 'Get an AnswerEvaluator'; + isa_ok $evaluator, 'AnswerEvaluator'; + can_ok $evaluator, [ qw/ evaluate / ], 'We Can Evaluate'; +}; + + +subtest 'Check attributes' => sub { + is( + $joule, + { + data => [ 1 ], + units => 'J', + units_ref => { kg => 1, m => 2, s => -2, factor => 1, + amp => 0, cd => 0, mol => 0, rad => 0, + degC => 0, degF => 0, degK => 0, + }, + isValue => T(), + context => check_isa 'Parser::Context', + }, + 'This looks like a joule' +); +}; + +subtest 'Basic definitions of energy equivalence' => sub { + is $joule->{data}->[0], $Nm->{data}->[0], 'One joule is one newton-metre'; + is $joule->getUnits, $Nm->getUnits, 'A joule has the same dimensions as a newton-metre'; + + is (check_score($joule, $Nm), 1, 'A Joule is a Newton-metre'); + is (check_score($joule, $energy_base_units), 1, 'A Joule can be expressed in SI base units'); +}; + +subtest 'Test error handling' => sub { + my $fake = 'bleurg'; + + like( + dies { NumberWithUnits(1, "$fake") }, + qr/Unrecognizable unit: \|$fake\|/, + "No unit '$fake' defined in Units file" + ); + like( + dies { NumberWithUnits(1) }, + qr/You must provide units for your number/, + "No unit given" + ); + like( + dies { NumberWithUnits('J') }, + qr/You must provide units for your number/, + "No value given, wants 2 arguments" + ); +}; + +subtest 'Check parsing of arguments' => sub { + ok my $three_args = NumberWithUnits(1, 'N', 'm'), 'Ignores extra argument'; + is $three_args->string, '1 N', 'Only sees the first unit'; + + ok my $string_arg = NumberWithUnits('1J'), 'Parses string argument'; + is $string_arg->string, '1 J', 'Parses string correctly'; +}; + +subtest 'Check some known units' => sub { + ok my @unit_names = (split /\|/, $joule->getUnitNames), 'Can getUnitNames'; + + is \@unit_names, + bag { + all_items( match qr/^[-%\w]+$/ ); + item 'J'; item 'N'; + item 'm'; item 'kg'; item 's'; + etc; + }, + 'Basic units loaded, sanity check'; +}; + +subtest 'Check other methods' => sub { + is [ $joule->splitUnits ], ['1', 'J'], 'splitUnits creates an array'; + + is $joule->adjustCorrectValue, 0, 'What is adjustCorrectValue?'; +}; + +subtest 'Check display methods' => sub { + is $joule->string, '1 J', 'Displays string - Joule'; + is $joule->TeX, '1\ {\rm J}', 'Displays LaTeX string - Joule'; + is $joule->TeXunits, '{\rm 1 J}', 'Displays LaTeX string - Joule'; + is $Nm->TeX, '1\ {\rm N\,m}', 'Displays LaTeX string - Newton metre'; + like $energy_base_units->TeX, + qr/ 1\\ \s \{ \S* \\frac\{ \\rm\S* \s kg \\, m\^\{2\}\} \{\\rm\S* \s s\^\{2\}\}\} /x, + 'Displays LaTeX string - energy in SI base units'; + + ok my $celsius = NumberWithUnits(1, 'degC'); + ok my $kelvin = NumberWithUnits(1, 'degK'); + todo 'Fix the display of temperatures' => sub { + is $celsius->TeX, '1\ {\rm ^{\circ}C}', 'Displays LaTeX string for degrees (finally)'; + is $kelvin->TeX, '1\ {\rm K}', 'Displays LaTeX string for kelvin, no degree sign'; + }; +}; + +subtest 'Check possible answer format branches' => sub { + # re-write without check_score so we can get the messages to students + + is check_score($joule, '1 J'), 1, 'one Joule plain'; + is check_score($joule, '1.00 J'), 1, 'one Joule float'; + is check_score($joule, '1E0 J'), 1, 'one Joule exponential notation'; + is check_score($joule, '7/7 J'), 1, 'one Joule value calculated'; + is check_score($joule, '1 J^1'), 1, 'one Joule to the power of one'; + is check_score($joule, 'J 1'), 0, 'one Joule wrong order'; + is check_score($joule, '2 J'), 0, 'one Joule wrong value'; + is check_score($joule, '1 j'), 0, 'one Joule wrong case'; + is check_score($joule, '1'), 0, 'one Joule missing unit'; + is check_score($joule, 'J'), 0, 'one Joule missing value'; + is check_score($joule, '1J'), 1, 'one Joule missing space between value and unit is valid'; + is check_score($joule, '1 N'), 0, 'one Joule wrong unit force not energy'; + is check_score($joule, '1 Nm'), 0, 'one Joule Nm missing *'; + is check_score($joule, '1 N*m'), 1, 'one Joule as Newton metre'; + is check_score($joule, '1 Joule'), 0, 'one Joule in words'; + is check_score($joule, '1E-3 kJ'), 1, 'one Joule value as exponential'; +}; + +todo 'check_score is stateful. Cannot handle repeated calls' => sub { + is check_score($joule, '1E-3 kJ'), 1, 'one Joule value as exponential second call'; + is check_score($joule, '1E-3 kJ'), 1, 'one Joule value as exponential third call'; + + # the other tests I'd like to run + is check_score($joule, '0.001 kJ'), 1, 'one Joule decimal kJ'; + is check_score($joule, '1/1000 kJ'), 1, 'one Joule fractional kJ'; + is check_score($joule, '10^-3 kJ'), 1, 'one Joule latex power kJ'; + is check_score($joule, '1 x 10^-3 kJ'), 1, 'one Joule scientific notation'; + is check_score($joule, '10**-3 kJ'), 1, 'one Joule power of 10 kJ'; +}; + + +done_testing; diff --git a/t/units/electron_volts.t b/t/units/electron_volts.t new file mode 100644 index 0000000000..03b667f0f1 --- /dev/null +++ b/t/units/electron_volts.t @@ -0,0 +1,30 @@ +use Test2::V0; + +use Units; + +my %joule = evaluate_units('J'); +my %newton_metre = evaluate_units('N*m'); +my %base_units = evaluate_units('kg*m^2/s^2'); + +my %electron_volt = evaluate_units('eV'); +my %kev = evaluate_units('keV'); +my %mev = evaluate_units('MeV'); +my %gev = evaluate_units('GeV'); + +is \%electron_volt, by_factor( 1.6022E-19, \%joule ), + 'eV and joules differ by a factor of 1.6022 x 10^19'; +is \%kev, by_factor( 1000, \%electron_volt ), 'kilo is factor 1000'; +is \%mev, by_factor( 10**6, \%electron_volt ), 'mega is factor 10^6'; +is \%gev, by_factor( 10**9, \%electron_volt ), 'giga is factor 10^9'; + + +done_testing; + +sub by_factor { + my ($value, $unit) = @_; + my $new_unit = { %$unit }; # shallow copy hash values + + $new_unit->{factor} *= $value; + + return $new_unit; +}