test surface io

function test_suite=test_surface_io()
% tests for surface input/output
%
% #   For CoSMoMVPA's copyright information and license terms,   #
% #   see the COPYING file distributed with CoSMoMVPA.           #
    try % assignment of 'localfunctions' is necessary in Matlab >= 2016
        test_functions=localfunctions();
    catch % no problem; early Matlab versions can use initTestSuite fine
    end
    initTestSuite;

function test_surface_dataset_gifti()
    if cosmo_skip_test_if_no_external('gifti')
        return;
    end
    save_and_load('gii');

function test_surface_dataset_niml_dset()
    if cosmo_skip_test_if_no_external('afni')
        return;
    end
    save_and_load('niml_dset');

function test_surface_dataset_bv_smp()
    if cosmo_skip_test_if_no_external('neuroelf')
        return;
    end
    save_and_load('bv_smp');

function test_surface_dataset_pymvpa()
    save_and_load('pymvpa');

function test_surface_io_exceptions()
    aet_in=@(varargin)assertExceptionThrown(@()...
                    cosmo_surface_dataset(varargin{:}),'');
    aet_out=@(varargin)assertExceptionThrown(@()...
                    cosmo_map2surface(varargin{:}),'');
    tmp_fn=cosmo_make_temp_filename();

    aet_in(tmp_fn);
    aet_in(struct());
    aet_in({});

    ds=cosmo_synthetic_dataset('type','surface');
    aet_out(struct,'-bv_smp');
    aet_out(struct,'-gii');
    aet_out(struct,'-niml_dset');
    aet_out(ds,tmp_fn);
    aet_out(ds,'-foo');
    aet_out(ds,struct());
    aet_out(ds,{});


function test_pymvpa_3d_string_array()
    py_ds=struct();
    py_ds.samples=rand(3,4);
    py_ds.fa.node_indices=[0,3,4,6];

    sa_labels={'foo';'bar';'foobaz'};
    sa_labels_3d=reshape(strvcat(sa_labels),[3 1 6]);
    py_ds.sa.labels=sa_labels_3d;

    ds=cosmo_surface_dataset(py_ds);
    assertEqual(ds.sa.labels,sa_labels);


function props=format2props(format)
    f2p=struct();
    f2p.gii.ext='.gii';
    f2p.gii.writer=@(fn,x)save(x,fn);
    f2p.gii.reader=@(fn)gifti(fn);
    f2p.gii.cleaner=@do_nothing;
    f2p.gii.isa=@(x)isa(x,'gifti');

    f2p.bv_smp.ext='.smp';
    f2p.bv_smp.writer=@(fn,x)x.SaveAs(fn);
    f2p.bv_smp.reader=@read_bv_and_bless;
    f2p.bv_smp.cleaner=@(x)x.ClearObject();
    f2p.bv_smp.isa=@(x)isa(x,'xff');

    f2p.niml_dset.ext='.niml.dset';
    f2p.niml_dset.writer=@(fn,x)afni_niml_writesimple(x,fn);
    f2p.niml_dset.reader=@(fn)afni_niml_readsimple(fn);
    f2p.niml_dset.cleaner=@do_nothing;
    f2p.niml_dset.isa=@(x)isstruct(x) && isfield(x,'node_indices');

    f2p.pymvpa.ext='.mat';
    f2p.pymvpa.writer=@write_mat_struct;
    f2p.pymvpa.reader=@import_struct_data_with_error_if_illegal;
    f2p.pymvpa.cleaner=@do_nothing;
    f2p.pymvpa.map2surface=@(x,fn)write_mat_struct(fn,...
                                cosmo_map2surface(x,'','format','pymvpa'));
    f2p.pymvpa.isa=@(x)isstruct(x) && ...
                    cosmo_isfield(x,{'samples'}) && ...
                    ~cosmo_isfield(x,'a.fdim');

    props=f2p.(format);

function x=read_bv_and_bless(fn)
    x=xff(fn);
    neuroelf_bless_wrapper(x);

function x=do_nothing(x)
    % do nothing

function result=neuroelf_bless_wrapper(arg)
    % deals with recent neuroelf (>v1.1), where bless is deprecated
    s=warning('off','neuroelf:xff:deprecated');
    resetter=onCleanup(@()warning(s));

    result=bless(arg);

function write_mat_struct(fn,s)
    save(fn,'-struct','s');

function s=import_struct_data_with_error_if_illegal(fn)
    try
        s=load(fn);
    catch
        error('Unable to read %s',fn);
    end

function save_and_load(format)
    ds=cosmo_synthetic_dataset('type','surface','nchunks',1);
    nfeatures=size(ds.samples,2);
    ds.fa.node_indices=ds.fa.node_indices(nonid_randperm(nfeatures));

    if strcmp(format,'bv_smp')
        xff_count_orig=helper_count_xff_objects();
        % ensure count of objects does not increase
    else
        xff_count_orig=NaN;
        % also permute node indices
        ds.a.fdim.values{1}=ds.a.fdim.values{1}(nonid_randperm(nfeatures));
    end

    ds.sa=struct();
    ds.sa.stats={'Ftest(3,4)';'Zscore()'};
    ds.sa.labels={'label1';'label2'};


    props=format2props(format);
    ext=props.ext;
    tmp_fn2=cosmo_make_temp_filename('_tmp',ext);

    tmp_file_cleaner=onCleanup(@()delete(tmp_fn2));

    if isfield(props,'map2surface')
        mapper=props.map2surface;
    else
        mapper=@cosmo_map2surface;
    end

    helper_assert_memory_use_equal(format,xff_count_orig);
    mapper(ds,tmp_fn2);
    helper_assert_memory_use_equal(format,xff_count_orig);

    ds2=cosmo_surface_dataset(tmp_fn2);
    helper_assert_memory_use_equal(format,xff_count_orig);

    assert_dataset_equal(ds,ds2,format);

    if strcmp(format,'bv_smp')
        ds2.a.fdim.values{1}=ds.a.fdim.values{1}(...
                                    nonid_randperm(nfeatures));
        assertExceptionThrown(@()cosmo_map2surface(ds2,tmp_fn2),'');
        helper_assert_memory_use_equal(format,xff_count_orig);
    end


    o=cosmo_map2surface(ds,['-' format]);
    writer=props.writer;
    reader=props.reader;
    cleaner=props.cleaner;

    writer(tmp_fn2,o);
    cleaner(o);
    helper_assert_memory_use_equal(format,xff_count_orig);

    o2=reader(tmp_fn2);
    ds3=cosmo_surface_dataset(o2);

    assert_dataset_equal(ds,ds3,format);

    % extra format-specific tests
    switch format
        case 'gii'
            o3=gifti(struct('cdata',o2.cdata));
            ds4=cosmo_surface_dataset(o3);
            ds3.a.fdim.values{1}=1:nfeatures;
            assert_dataset_equal(ds3,ds4,format);
        case 'niml'
            o2=rmfield(o2,'node_indices');
            ds4=cosmo_surface_dataset(o2);
            ds3.a.fdim.values{1}=1:nfeatures;
            assert_dataset_equal(ds3,ds4,format);
    end

    % clean up intermediate objects
    cleaner(o2);

    helper_assert_memory_use_equal(format,xff_count_orig);

    % use format explicitly
    o4=cosmo_map2surface(ds,'','format',format);
    assert_isa_func=props.isa;
    assert_isa_func(o4);
    ds4=cosmo_surface_dataset(o4);
    assert_dataset_equal(ds,ds4,format);
    cleaner(o4);


    % test with dataset attributes
    targets=[3 4];
    chunks=5;
    ds4=cosmo_surface_dataset(ds,'targets',targets,'chunks',chunks);
    ds4_expected=ds;
    ds4_expected.sa.chunks=[chunks; chunks];
    ds4_expected.sa.targets=targets(:);
    assertEqual(ds4,ds4_expected);


    % make illegal dataset
    fid=fopen(tmp_fn2,'w');
    fprintf(fid,'foo');
    fclose(fid);

    switch format
        case 'bv_smp'
            exception_io_failed='xff:XFFioFailed';

            % neuroelf v<1.1 did not have the 'neuroelf:' prefix
            exception_bad_content={'xff:BadFileContent',...
                                    'neuroelf:xff:badFileContent'};

        otherwise
            exception_io_failed='';
            exception_bad_content='';
    end

    % file should not be readable
    assertExceptionThrown(@()reader(tmp_fn2),...
                                        exception_io_failed);

    % file should not exist
    tmp_fn2=cosmo_make_temp_filename();
    assert_helper_any_exception_thrown(@()reader(tmp_fn2),...
                                        exception_bad_content);

    helper_assert_memory_use_equal(format,xff_count_orig);


function helper_assert_memory_use_equal(format,xff_count_orig)
    if strcmp(format,'bv_smp')
        xff_count_final=helper_count_xff_objects();
        delta=xff_count_final-xff_count_orig;
        assert(delta==0,sprintf('count increased by %d',delta));
    end


function rp=nonid_randperm(n)
    assert(n>1);
    while true
        rp=randperm(n);
        if ~isequal(rp,1:n)
            return;
        end
    end

function assert_dataset_equal(x,y,format)
    mp=cosmo_align(y.fa.node_indices,x.fa.node_indices);
    mp2=cosmo_align(y.a.fdim.values{1},x.a.fdim.values{1});

    z=cosmo_slice(y,mp(mp2),2);
    z.a.fdim.values{1}=z.a.fdim.values{1}(mp2);
    assertEqual(x.a,z.a);
    assertEqual(x.fa,z.fa);
    assertElementsAlmostEqual(x.samples,z.samples,'absolute',1e-4);


    if ~strcmp(format,'gii')
        assertEqual(x.sa,z.sa);
    end

function assert_helper_any_exception_thrown(f, exception)
    if ischar(exception)
        assertExceptionThrown(f,exception);
        return
    end

    assert(iscell(exception))
    try
        f();
        error('no exception thrown');
    catch
        e=lasterror();
        i=strmatch(e.identifier,exception,'exact');
        if isempty(i)
            error('None of the following exceptions was thrown: %s',...
                    cosmo_strjoin(exception,', '));
        end
    end


function count=helper_count_xff_objects
    if cosmo_skip_test_if_no_external('!evalc')
        return;
    end

    xff_str=evalc('xff()');
    lines=cosmo_strsplit(xff_str,'\n');

    pre_idx=strmatch('   # | Type  | ',lines);
    line_idxs=strmatch('------------------',lines);

    if isempty(pre_idx)
        % Neuroelf < v1.1, no objects
        assert(isempty(line_idxs));
        count=0;
        return;
    end

    post_idx=line_idxs(line_idxs>(pre_idx+2));

    assert(numel(pre_idx)==1);

    if numel(post_idx)==0
        count=0;
        return;
    end

    assert(numel(post_idx)==1);

    offset=2;
    count=post_idx-pre_idx-offset;