Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/matlab-ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ python/plots/

python/docs/_build/
python/.pytest_cache/
test-results/
3 changes: 3 additions & 0 deletions docs/DEVPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
56 changes: 56 additions & 0 deletions run_tests.m
Original file line number Diff line number Diff line change
@@ -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

23 changes: 23 additions & 0 deletions tests/TestFixtures.m
Original file line number Diff line number Diff line change
@@ -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

39 changes: 39 additions & 0 deletions tests/TestParityAgainstBaseline.m
Original file line number Diff line number Diff line change
@@ -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

57 changes: 57 additions & 0 deletions tests/TestPlotStyleApi.m
Original file line number Diff line number Diff line change
@@ -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<NASGU>
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
Loading