From 8d07b384cdd0838a7040bfc3e2d1957e4eb1360a Mon Sep 17 00:00:00 2001 From: Iahn Cajigas Date: Thu, 5 Mar 2026 14:46:09 -0500 Subject: [PATCH] PR2: add matlab.unittest suite and MATLAB CI workflow Parity: PASS (numeric) Plots: PASS (structure) --- .github/workflows/matlab-ci.yml | 38 +++++++++++++++++++++ .gitignore | 1 + docs/DEVPLAN.md | 3 ++ run_tests.m | 56 ++++++++++++++++++++++++++++++ tests/TestFixtures.m | 23 +++++++++++++ tests/TestParityAgainstBaseline.m | 39 +++++++++++++++++++++ tests/TestPlotStyleApi.m | 57 +++++++++++++++++++++++++++++++ 7 files changed, 217 insertions(+) create mode 100644 .github/workflows/matlab-ci.yml create mode 100644 run_tests.m create mode 100644 tests/TestFixtures.m create mode 100644 tests/TestParityAgainstBaseline.m create mode 100644 tests/TestPlotStyleApi.m diff --git a/.github/workflows/matlab-ci.yml b/.github/workflows/matlab-ci.yml new file mode 100644 index 0000000..790ee49 --- /dev/null +++ b/.github/workflows/matlab-ci.yml @@ -0,0 +1,38 @@ +name: MATLAB CI + +on: + pull_request: + push: + branches: + - master + - main + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + matlab-tests: + runs-on: [self-hosted, macOS] + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + lfs: true + + - name: Run MATLAB tests + uses: matlab-actions/run-command@v2 + with: + command: >- + cd('${{ github.workspace }}'); + addpath(fullfile(pwd,'tools')); + run_tests('IncludeParity',true,'OutputXml',fullfile('test-results','results.xml')); + + - name: Upload JUnit test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: matlab-test-results + path: test-results/results.xml diff --git a/.gitignore b/.gitignore index 7d0bcea..f157528 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ python/plots/ python/docs/_build/ python/.pytest_cache/ +test-results/ diff --git a/docs/DEVPLAN.md b/docs/DEVPLAN.md index 3ee2178..130a3cf 100644 --- a/docs/DEVPLAN.md +++ b/docs/DEVPLAN.md @@ -99,4 +99,7 @@ run_all_checks('GenerateBaseline',true,'CheckParity',true,'Style','legacy'); % Routine parity check (post-change) run_all_checks('GenerateBaseline',false,'CheckParity',true,'Style','legacy'); + +% Run matlab.unittest suite +run_tests('IncludeParity',true); ``` diff --git a/run_tests.m b/run_tests.m new file mode 100644 index 0000000..71bfc58 --- /dev/null +++ b/run_tests.m @@ -0,0 +1,56 @@ +function results = run_tests(varargin) +%RUN_TESTS Run nSTAT matlab.unittest suite. +% +% Syntax: +% run_tests +% results = run_tests('IncludeParity',true) +% +% Name-Value: +% IncludeParity - Run fixture-backed parity integration tests (default true) +% OutputXml - Path to JUnit XML output file (default test-results/results.xml) +% +% Output: +% results - matlab.unittest result array + +opts = parseOptions(varargin{:}); + +rootDir = fileparts(mfilename('fullpath')); +addpath(fullfile(rootDir, 'tools')); +cd(rootDir); + +if ~opts.IncludeParity + setenv('NSTAT_SKIP_PARITY_TESTS', '1'); +else + setenv('NSTAT_SKIP_PARITY_TESTS', '0'); +end + +suite = testsuite(fullfile(rootDir, 'tests'), 'IncludeSubfolders', true); +runner = matlab.unittest.TestRunner.withTextOutput('OutputDetail', matlab.unittest.Verbosity.Detailed); + +xmlPath = opts.OutputXml; +xmlDir = fileparts(xmlPath); +if ~isempty(xmlDir) && exist(xmlDir, 'dir') ~= 7 + mkdir(xmlDir); +end +runner.addPlugin(matlab.unittest.plugins.XMLPlugin.producingJUnitFormat(xmlPath)); + +results = runner.run(suite); + +if any([results.Failed]) + failed = sum([results.Failed]); + error('nstat:tests:Failed', 'MATLAB tests failed (%d failing tests).', failed); +end +end + +function opts = parseOptions(varargin) +parser = inputParser; +parser.FunctionName = 'run_tests'; +addParameter(parser, 'IncludeParity', true, @(x)islogical(x) || (isnumeric(x) && isscalar(x))); +addParameter(parser, 'OutputXml', fullfile('test-results', 'results.xml'), @(x)ischar(x) || (isstring(x) && isscalar(x))); +parse(parser, varargin{:}); + +opts = parser.Results; +opts.IncludeParity = logical(opts.IncludeParity); +opts.OutputXml = char(string(opts.OutputXml)); +end + diff --git a/tests/TestFixtures.m b/tests/TestFixtures.m new file mode 100644 index 0000000..57f0cc1 --- /dev/null +++ b/tests/TestFixtures.m @@ -0,0 +1,23 @@ +classdef TestFixtures < matlab.unittest.TestCase + %TESTFIXTURES Validate that baseline fixture files exist. + + properties (Constant, Access = private) + RequiredFiles = { + fullfile('fixtures', 'baseline_numeric', 'nSTATPaperExamples_numeric_baseline.mat') + fullfile('fixtures', 'baseline_numeric', 'nSTATPaperExamples_numeric_baseline.json') + fullfile('fixtures', 'baseline_plot_structure.json') + fullfile('fixtures', 'baseline_figures_legacy', 'figure_001.png') + }; + end + + methods (Test) + function testBaselineFilesPresent(tc) + rootDir = fileparts(fileparts(mfilename('fullpath'))); + for i = 1:numel(tc.RequiredFiles) + p = fullfile(rootDir, tc.RequiredFiles{i}); + tc.verifyEqual(exist(p, 'file'), 2, sprintf('Missing fixture file: %s', p)); + end + end + end +end + diff --git a/tests/TestParityAgainstBaseline.m b/tests/TestParityAgainstBaseline.m new file mode 100644 index 0000000..bdf91a6 --- /dev/null +++ b/tests/TestParityAgainstBaseline.m @@ -0,0 +1,39 @@ +classdef TestParityAgainstBaseline < matlab.unittest.TestCase + %TESTPARITYAGAINSTBASELINE Integration parity tests for paper examples. + + properties (Access = private) + RootDir char + end + + methods (TestClassSetup) + function setup(tc) + tc.RootDir = fileparts(fileparts(mfilename('fullpath'))); + addpath(fullfile(tc.RootDir, 'tools')); + cd(tc.RootDir); + end + end + + methods (Test) + function testLegacyParity(tc) + tc.assumeFalse(skipParityTests, 'Skipping parity integration tests via NSTAT_SKIP_PARITY_TESTS'); + report = check_parity_against_baseline('Seed', 0, 'Style', 'legacy', 'CheckPixels', false); + tc.verifyTrue(report.passed); + tc.verifyTrue(report.numeric.passed); + tc.verifyTrue(report.plotStructure.passed); + end + + function testModernParity(tc) + tc.assumeFalse(skipParityTests, 'Skipping parity integration tests via NSTAT_SKIP_PARITY_TESTS'); + report = check_parity_against_baseline('Seed', 0, 'Style', 'modern', 'CheckPixels', false); + tc.verifyTrue(report.passed); + tc.verifyTrue(report.numeric.passed); + tc.verifyTrue(report.plotStructure.passed); + end + end +end + +function tf = skipParityTests +val = getenv('NSTAT_SKIP_PARITY_TESTS'); +tf = strcmpi(strtrim(val), '1') || strcmpi(strtrim(val), 'true'); +end + diff --git a/tests/TestPlotStyleApi.m b/tests/TestPlotStyleApi.m new file mode 100644 index 0000000..f094c02 --- /dev/null +++ b/tests/TestPlotStyleApi.m @@ -0,0 +1,57 @@ +classdef TestPlotStyleApi < matlab.unittest.TestCase + %TESTPLOTSTYLEAPI Unit tests for nstat plot style helpers. + + properties (Access = private) + HadPref (1,1) logical = false + PreviousStyle char = '' + end + + methods (TestMethodSetup) + function snapshotPreference(tc) + if ispref('nstat', 'PlotStyle') + tc.HadPref = true; + tc.PreviousStyle = getpref('nstat', 'PlotStyle'); + else + tc.HadPref = false; + tc.PreviousStyle = ''; + end + end + end + + methods (TestMethodTeardown) + function restorePreference(tc) + if tc.HadPref + setpref('nstat', 'PlotStyle', tc.PreviousStyle); + elseif ispref('nstat', 'PlotStyle') + rmpref('nstat', 'PlotStyle'); + end + end + end + + methods (Test) + function testSetAndGetRoundTrip(tc) + addpath(fullfile(fileparts(fileparts(mfilename('fullpath'))), 'tools')); + tc.assumeEqual(exist('nstat.setPlotStyle', 'file'), 2, ... + 'Plot style API not available on this branch.'); + nstat.setPlotStyle('legacy'); + tc.verifyEqual(nstat.getPlotStyle, 'legacy'); + + nstat.setPlotStyle('modern'); + tc.verifyEqual(nstat.getPlotStyle, 'modern'); + end + + function testApplyStyleDoesNotCreateLegend(tc) + addpath(fullfile(fileparts(fileparts(mfilename('fullpath'))), 'tools')); + tc.assumeEqual(exist('nstat.applyPlotStyle', 'file'), 2, ... + 'Plot style API not available on this branch.'); + f = figure('Visible', 'off'); + c = onCleanup(@()close(f)); %#ok + ax = axes('Parent', f); + plot(ax, 1:10, randn(1, 10)); + + tc.verifyEmpty(findall(f, 'Type', 'Legend')); + nstat.applyPlotStyle(ax, 'Style', 'modern'); + tc.verifyEmpty(findall(f, 'Type', 'Legend')); + end + end +end