Skip to content

Commit 34c8415

Browse files
torwagerclaude
andauthored
Integrate two old_to_integrate tests; harden test discovery (#80)
* Integrate two old_to_integrate tests; harden test discovery Convert two quarantined standalone scripts into real matlab.unittest tests and move them into the discovered suite, and make the runner robust against non-test files that match the canlab_test_*.m glob. - image_vector/canlab_test_check_roi_extraction.m: converts old_to_integrate/check_roi_extraction.m. The original printed PASS/FAIL but never asserted. Now asserts multi-region 'unique_mask_values' extraction and invariance of the region averages across a write->reload round-trip (RelTol 1e-3 / AbsTol 1e-2 absorbs single-precision NIfTI rounding). Complements canlab_test_extract_roi (single-mask only). - image_vector/canlab_test_resampling_pattern_expression.m: converts old_to_integrate/resampling_pattern_expression_unit_test1.m, which only plotted. Now asserts SIIPS pattern expression is stable (corr > 0.99) across default/nearest/spline resampling; skips if SIIPS is unavailable. - helpers/canlab_safe_suite_from_file.m + canlab_run_all_tests.m: wrap TestSuite.fromFile so a stray non-test canlab_test_*.m is warn-skipped instead of throwing NonTestFile and aborting discovery of the whole suite. - canlab_test_runner_robustness.m: pins that behavior (non-test file -> warn + empty + ok=false; valid file -> loads). Old originals removed from old_to_integrate (jackknife_similarity_unit_test.m stays - it is a characterization/figure script, not a unit test). Full default suite after: 147 passed, 0 failed, 2 incomplete (pre-existing environment skips). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Convert 5 xval_* tests to matlab.unittest; move to predictive_model/ The five xval_*_unit_test.m files at the Unit_tests root were genuine tests (real assertions, no graphics, deterministic, bundled DPSP / synthetic data) but were plain-assert functions named outside the canlab_test_* convention, so the runner never discovered them. Convert each into a functiontests file under a new predictive_model/ subdir: the model is fit once in setupOnce (skipped via assumeTrue if the DPSP sample data is absent) and cached, and the assertions are split into focused test functions using tc.verify* for granular reporting. Behavior preserved. - predictive_model/canlab_test_xval_SVM.m - predictive_model/canlab_test_xval_SVR.m - predictive_model/canlab_test_xval_regression_multisubject.m - predictive_model/canlab_test_xval_regression_multisubject_featureselect.m - predictive_model/canlab_test_xval_discriminant_classifier.m - helpers/canlab_get_dpsp_hot_warm.m: shared DPSP Hot/Warm loader. Old xval_*_unit_test.m removed. New suite: 25 test points, all pass, ~7.5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Convert predictive_model_unit_test to matlab.unittest (minimal nboot/nperm) predictive_model_unit_test.m exercised the @predictive_model object's own public API (fit/predict/score/crossval/bootstrap/weight_map_object/ permutation_test/calibrate/select_features/grid_search/stability_selection/ pipeline/newapi routing/regression+report/summary). This is non-redundant with the xval_* tests, which cover the legacy wrapper functions rather than the object methods - so it is worth keeping. Convert to predictive_model/canlab_test_predictive_model_api.m: the shared fit -> crossval -> bootstrap chain is computed once in setupOnce and cached; the rest are focused test functions using tc.verify*; visualisation methods (plot/confusionchart/montage) are smoke-tested and skipped on a headless runner via canlab_classify_environment_error. Drop the bootstrap/permutation/stability counts to minimum-viable values (nboot 25->5, nperm 10->3, stability nboot 20->5) since this is a smoke test, not real inference; assertions are structural (shapes/ranges/p-floor), so the counts only need to exercise the code paths. Runtime 131s -> 73s locally; the residual cost is the high-dimensional (194,676-feature) cross-validation, not the resampling counts. Skipped if DPSP_hotwarm is not on the path. Old predictive_model_unit_test.m removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * predictive_model API test: mask to sparse gray-matter ROI for speed The dominant cost was cross-validating on the full ~195k-voxel volume. In setupOnce, restrict hw_obj to gray matter (gray_matter_mask.img) and then deterministically thin to every 8th voxel (~20k features). hw_obj and X stay in lockstep, so weight_map_object / montage back-project consistently, and all assertions are structural so masking does not change them. Falls back to the full volume if the mask is not on the path. Runtime: 131s (original) -> 73s (minimal nboot/nperm) -> ~20s now. All 17 test points still pass; model behaviour unchanged (AUC ~0.83). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1f6ec61 commit 34c8415

20 files changed

Lines changed: 957 additions & 662 deletions

CanlabCore/Unit_tests/canlab_run_all_tests.m

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@
6363
case 'include'
6464
% run everything
6565
end
66-
suite = [suite, TestSuite.fromFile(fpath)]; %#ok<AGROW>
66+
% canlab_safe_suite_from_file warn-skips files that are not valid test
67+
% files instead of letting a NonTestFile error abort the whole run.
68+
suite = [suite, canlab_safe_suite_from_file(fpath)]; %#ok<AGROW>
6769
end
6870

6971
tag = char(p.Results.Tag);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
function tests = canlab_test_runner_robustness
2+
%CANLAB_TEST_RUNNER_ROBUSTNESS Discovery must not abort on a stray non-test file.
3+
%
4+
% canlab_run_all_tests globs every canlab_test_*.m under Unit_tests and
5+
% concatenates the suites. A file that matches the name pattern but is not a
6+
% valid matlab.unittest test (an old plain-assert script, or a function whose
7+
% internal name does not match the filename) makes TestSuite.fromFile throw
8+
% MATLAB:unittest:TestSuite:NonTestFile. Without guarding, that single error
9+
% aborts discovery of the entire suite. canlab_safe_suite_from_file warn-skips
10+
% such files instead; these tests pin that behavior.
11+
12+
tests = functiontests(localfunctions);
13+
end
14+
15+
16+
function test_nontest_file_is_warn_skipped(tc) %#ok<*DEFNU>
17+
% A canlab_test_*.m whose function name != filename and that never calls
18+
% functiontests is not a valid test file: skip it, warn, return empty.
19+
folder = tc.applyFixture( ...
20+
matlab.unittest.fixtures.TemporaryFolderFixture).Folder;
21+
fpath = local_write(folder, 'canlab_test_bogus_nontest.m', { ...
22+
'function some_other_name()'
23+
'assert(true);'
24+
'end'});
25+
26+
% Use lastwarn rather than verifyWarning so the check does not depend on the
27+
% ambient warning display state (a caller may have warnings off; lastwarn
28+
% still records the id even when the warning event is not displayed).
29+
lastwarn('', '');
30+
[suite, ok] = canlab_safe_suite_from_file(fpath);
31+
[~, warn_id] = lastwarn;
32+
33+
tc.verifyFalse(ok, 'expected ok=false for a non-test file');
34+
tc.verifyEmpty(suite, 'expected an empty suite for a non-test file');
35+
tc.verifyEqual(warn_id, 'canlab_run_all_tests:skippedNonTestFile', ...
36+
'expected a skippedNonTestFile warning');
37+
end
38+
39+
40+
function test_valid_test_file_is_loaded(tc)
41+
% A well-formed functiontests file loads normally (ok=true, non-empty suite).
42+
folder = tc.applyFixture( ...
43+
matlab.unittest.fixtures.TemporaryFolderFixture).Folder;
44+
fpath = local_write(folder, 'canlab_test_valid_dummy.m', { ...
45+
'function tests = canlab_test_valid_dummy'
46+
'tests = functiontests(localfunctions);'
47+
'end'
48+
''
49+
'function test_trivial(tc)'
50+
'tc.verifyTrue(true);'
51+
'end'});
52+
53+
[suite, ok] = canlab_safe_suite_from_file(fpath);
54+
tc.verifyTrue(ok, 'expected ok=true for a valid test file');
55+
tc.verifyNotEmpty(suite);
56+
tc.verifyEqual(numel(suite), 1);
57+
end
58+
59+
60+
function fpath = local_write(folder, name, lines)
61+
% Write a cellstr of lines to folder/name and return the full path. Uses
62+
% fopen/fprintf (not writelines) so the test runs on older MATLAB too.
63+
fpath = fullfile(folder, name);
64+
fid = fopen(fpath, 'w');
65+
tc_assert_open(fid, fpath);
66+
cleanup = onCleanup(@() fclose(fid));
67+
fprintf(fid, '%s\n', lines{:});
68+
end
69+
70+
71+
function tc_assert_open(fid, fpath)
72+
if fid < 0
73+
error('canlab_test_runner_robustness:cannotWrite', ...
74+
'Could not open %s for writing.', fpath);
75+
end
76+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
function [hot, warm, ok] = canlab_get_dpsp_hot_warm()
2+
%CANLAB_GET_DPSP_HOT_WARM Load the DPSP single-subject Hot/Warm sample maps.
3+
%
4+
% [hot, warm, ok] = canlab_get_dpsp_hot_warm()
5+
%
6+
% Returns the single-subject Hot and Warm condition maps from
7+
% Sample_datasets/DPSP_pain_rejection_participant_maps as fmri_data objects.
8+
% Used by the predictive_model / xval_* tests so the load semantics live in
9+
% one place. ok is false (and hot/warm are []) if the sample files are not
10+
% on the path, so callers can assume/skip gracefully.
11+
12+
sample_dir = fullfile(fileparts(fileparts(which('fmri_data'))), ...
13+
'Sample_datasets', 'DPSP_pain_rejection_participant_maps');
14+
hot_file = fullfile(sample_dir, 'DPSP_single_subject_images_hot.mat');
15+
warm_file = fullfile(sample_dir, 'DPSP_single_subject_images_warm.mat');
16+
17+
ok = exist(hot_file, 'file') == 2 && exist(warm_file, 'file') == 2;
18+
hot = [];
19+
warm = [];
20+
21+
if ok
22+
H = load(hot_file);
23+
W = load(warm_file);
24+
hot = H.single_subject_images_hot;
25+
warm = W.single_subject_images_warm;
26+
end
27+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
function [suite, ok] = canlab_safe_suite_from_file(fpath)
2+
%CANLAB_SAFE_SUITE_FROM_FILE Build a test suite from one file without aborting on bad files.
3+
%
4+
% [suite, ok] = canlab_safe_suite_from_file(fpath)
5+
%
6+
% Wraps matlab.unittest.TestSuite.fromFile so that a file which is not a
7+
% valid test (e.g. a stray canlab_test_*.m that is an old plain-assert
8+
% script or whose internal function name does not match the filename)
9+
% does not throw and abort discovery of the whole suite. Such a file is
10+
% warn-skipped and an empty Test array is returned instead.
11+
%
12+
% This matters because canlab_run_all_tests globs every canlab_test_*.m
13+
% under Unit_tests and concatenates the results; a single NonTestFile
14+
% error from fromFile would otherwise take down the entire run.
15+
%
16+
% :Inputs:
17+
% **fpath:** absolute path to a candidate .m test file.
18+
%
19+
% :Outputs:
20+
% **suite:** a matlab.unittest.Test array (empty if the file is not a
21+
% valid test file).
22+
% **ok:** logical, true if fromFile succeeded, false if the file was
23+
% skipped.
24+
%
25+
% :See also: canlab_run_all_tests, matlab.unittest.TestSuite
26+
27+
ok = true;
28+
suite = matlab.unittest.Test.empty;
29+
30+
try
31+
suite = matlab.unittest.TestSuite.fromFile(fpath);
32+
catch ME
33+
ok = false;
34+
if strcmp(ME.identifier, 'MATLAB:unittest:TestSuite:NonTestFile')
35+
warning('canlab_run_all_tests:skippedNonTestFile', ...
36+
'Skipping %s: not a valid matlab.unittest test file.', fpath);
37+
else
38+
warning('canlab_run_all_tests:fromFileError', ...
39+
'Skipping %s: %s (%s)', fpath, ME.message, ME.identifier);
40+
end
41+
end
42+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
function tests = canlab_test_check_roi_extraction
2+
%CANLAB_TEST_CHECK_ROI_EXTRACTION Multi-region ROI extraction + write/reload round-trip.
3+
%
4+
% Complements canlab_test_extract_roi (single-mask extraction) by exercising
5+
% the two things that test does not:
6+
% 1. Multi-region extraction with 'unique_mask_values' (one average per
7+
% integer label in a parcellation), using atlas_labels_combined.img.
8+
% 2. Invariance of the extracted region averages across a write-to-disk and
9+
% reload cycle - a regression guard on the NIfTI I/O path.
10+
%
11+
% Converted from the old standalone script Unit_tests/old_to_integrate/
12+
% check_roi_extraction.m, which printed PASS/FAIL but never asserted.
13+
14+
tests = functiontests(localfunctions);
15+
end
16+
17+
18+
function test_unique_mask_values_roundtrip(tc) %#ok<*DEFNU>
19+
mask_file = which('atlas_labels_combined.img');
20+
tc.assumeNotEmpty(mask_file, 'atlas_labels_combined.img not on path');
21+
22+
mask_image = fmri_data(mask_file, 'noverbose');
23+
24+
% Synthetic per-region timeseries: region i carries the signal ts*sqrt(i),
25+
% so every region has a distinct, known mean trajectory across images.
26+
wh_region = mask_image.dat;
27+
regions = unique(wh_region);
28+
nimgs = 20;
29+
ts = linspace(-10, 10, nimgs);
30+
31+
dat = mask_image;
32+
dat.dat = zeros(size(mask_image.dat, 1), nimgs);
33+
for i = 1:numel(regions)
34+
idx = wh_region == regions(i);
35+
dat.dat(idx, :) = repmat(ts .* sqrt(i), sum(idx), 1);
36+
end
37+
38+
cl1 = extract_roi_averages(dat, mask_image, 'unique_mask_values');
39+
all_reg1 = cat(2, cl1(:).dat);
40+
41+
% One row per image, one column per non-zero region label.
42+
tc.verifyEqual(size(all_reg1, 1), nimgs);
43+
tc.verifyGreaterThan(size(all_reg1, 2), 1);
44+
45+
% Round-trip through disk in a scratch folder, then re-extract.
46+
tc.applyFixture(matlab.unittest.fixtures.WorkingFolderFixture);
47+
dat.fullpath = fullfile(pwd, 'test_roi_image.nii');
48+
write(dat, 'overwrite');
49+
reloaded = fmri_data(dat.fullpath, 'noverbose');
50+
51+
cl2 = extract_roi_averages(reloaded, mask_image, 'unique_mask_values');
52+
all_reg2 = cat(2, cl2(:).dat);
53+
54+
tc.verifyEqual(size(all_reg2), size(all_reg1));
55+
% Small absolute slack absorbs single-precision NIfTI write rounding (the
56+
% original script used a 1e-3 relative threshold for the same reason).
57+
tc.verifyEqual(all_reg2, all_reg1, 'AbsTol', 1e-2, 'RelTol', 1e-3);
58+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
function tests = canlab_test_resampling_pattern_expression
2+
%CANLAB_TEST_RESAMPLING_PATTERN_EXPRESSION Pattern expression is stable across resampling.
3+
%
4+
% apply_mask(..., 'pattern_expression') resamples the weight map into the
5+
% data space when the two differ. This test checks that the resulting
6+
% pattern-expression scores barely change regardless of which interpolation
7+
% method does that resampling (default vs nearest vs spline) - i.e. the
8+
% dot-product readout is not an artifact of the interpolation choice.
9+
%
10+
% Uses the SIIPS signature against the emotionreg sample. Skipped if the
11+
% SIIPS image set is not available on the path (it ships with
12+
% Neuroimaging_Pattern_Masks).
13+
%
14+
% Converted from the old standalone visual script
15+
% Unit_tests/old_to_integrate/resampling_pattern_expression_unit_test1.m,
16+
% which only plotted the variants and asserted nothing.
17+
18+
tests = functiontests(localfunctions);
19+
end
20+
21+
22+
function test_pattern_expression_stable_across_interp(tc) %#ok<*DEFNU>
23+
obj = canlab_get_sample_fmri_data();
24+
25+
try
26+
siips = load_image_set('siips', 'noverbose');
27+
catch ME
28+
tc.assumeFail(['SIIPS signature not available on this runner: ' ME.message]);
29+
end
30+
tc.assumeNotEmpty(siips.dat, 'SIIPS loaded but empty');
31+
32+
n = size(obj.dat, 2);
33+
34+
% Default path lets apply_mask resample internally; the other two pre-resample
35+
% the weight map with an explicit interpolation method.
36+
pe_default = apply_mask(obj, siips, 'pattern_expression');
37+
pe_nearest = apply_mask(obj, resample_space(siips, obj, 'nearest'), 'pattern_expression');
38+
pe_spline = apply_mask(obj, resample_space(siips, obj, 'spline'), 'pattern_expression');
39+
40+
for v = {pe_default, pe_nearest, pe_spline}
41+
tc.verifyEqual(numel(v{1}), n);
42+
tc.verifyTrue(all(isfinite(v{1})), 'pattern expression returned non-finite values');
43+
end
44+
45+
% Empirically these correlate at >= 0.9999; 0.99 is a safe regression floor.
46+
tc.verifyGreaterThan(corr(pe_default, pe_nearest), 0.99);
47+
tc.verifyGreaterThan(corr(pe_default, pe_spline), 0.99);
48+
end

CanlabCore/Unit_tests/old_to_integrate/check_roi_extraction.m

Lines changed: 0 additions & 64 deletions
This file was deleted.

CanlabCore/Unit_tests/old_to_integrate/resampling_pattern_expression_unit_test1.m

Lines changed: 0 additions & 40 deletions
This file was deleted.

0 commit comments

Comments
 (0)