diff --git a/code/+nansen/+manage/detectNewSessions.m b/code/+nansen/+manage/detectNewSessions.m index e347018c5..d7d21feca 100644 --- a/code/+nansen/+manage/detectNewSessions.m +++ b/code/+nansen/+manage/detectNewSessions.m @@ -5,7 +5,8 @@ % look for session folders based on the current datalocation model % (i.e current project) and make a list of session objects based on % folders. Session objects for all sessions that are not present in the -% table is returned. +% table is returned. The session objects are of the class of the +% metatable (its MetaTableClass). % % INPUTS: % metaTable : a session metatable @@ -36,8 +37,9 @@ return end - % Todo: Get schema based on selection - sessionSchema = @nansen.metadata.type.Session; + % New sessions are of the table's class, so that their columns match + % the rows of the table + sessionSchema = str2func(metaTable.MetaTableClass); args = {'DataLocationModel', dataLocationModel}; % Create a list of session metadata objects diff --git a/code/+nansen/+manage/updateSubjectTable.m b/code/+nansen/+manage/updateSubjectTable.m index 1ad47974e..183e77ef7 100644 --- a/code/+nansen/+manage/updateSubjectTable.m +++ b/code/+nansen/+manage/updateSubjectTable.m @@ -1,13 +1,22 @@ function updateSubjectTable(metatableCatalog, subjectSchema) - - if nargin < 2 - subjectSchema = 'nansen.metadata.type.Subject'; - end +%updateSubjectTable Add the subjects of the session table that the subject table lacks +% +% updateSubjectTable(metatableCatalog) adds a row to the master subject +% table for each subject ID in the master session table that the subject +% table does not have. The new subjects are of the subject table's class, +% so that their columns match the existing rows. +% +% updateSubjectTable(metatableCatalog, subjectSchema) creates the new +% subjects with the class named by subjectSchema. % Find master session table from metatable catalog sessionTable = metatableCatalog.getMasterMetaTable('session'); subjectTable = metatableCatalog.getMasterMetaTable('subject'); + if nargin < 2 + subjectSchema = subjectTable.MetaTableClass; + end + try uniqueSubjectIds = unique( sessionTable.entries.subjectID ); @@ -20,8 +29,8 @@ function updateSubjectTable(metatableCatalog, subjectSchema) % Create subjects. subjectArray(numSubjects) = feval(subjectSchema); %#ok - for i = 1:numel(uniqueSubjectIds) - subjectArray(i).SubjectID = uniqueSubjectIds{i}; + for i = 1:numSubjects + subjectArray(i).SubjectID = newSubjectIds{i}; end catch return diff --git a/code/+nansen/+metadata/+type/Session.m b/code/+nansen/+metadata/+type/Session.m index ddd58b93c..bc0611113 100644 --- a/code/+nansen/+metadata/+type/Session.m +++ b/code/+nansen/+metadata/+type/Session.m @@ -375,10 +375,12 @@ function refreshDataLocations(obj) end rootUid = obj(iObj).DataLocation(jDl).RootUid; - if isempty(rootUid) + if isempty(rootUid) && ~isempty(S(jDl).RootPath) % If the rootUid is empty, use the last available % rootpath from the model, using this as the - % automatic location. + % automatic location. A data location that has no + % root path yet (a new project's default location + % before it is configured) leaves it empty. rootUid = S(jDl).RootPath(end).Key; obj(iObj).DataLocation(jDl).RootUid = rootUid; end diff --git a/code/+nansen/+metadata/@MetaTable/MetaTable.m b/code/+nansen/+metadata/@MetaTable/MetaTable.m index 7f0cfbcad..b5b1b4603 100644 --- a/code/+nansen/+metadata/@MetaTable/MetaTable.m +++ b/code/+nansen/+metadata/@MetaTable/MetaTable.m @@ -1170,6 +1170,19 @@ function fromFileStruct(obj, S) error('NANSEN:MetaTable:InvalidFileType', ... 'The file "%s" does not contain a MetaTable', fileName) end + + % A table saved without the name of its ID column takes it from + % its class (SchemaIdName), so the class must be on the path + className = char(S.MetaTableClass); + hasIdVarname = isfield(S, 'MetaTableIdVarname') && ~isempty(S.MetaTableIdVarname); + if ~hasIdVarname && ~isempty(className) && ~strcmp(className, 'table') ... + && isempty(meta.class.fromName(className)) + [~, fileName] = fileparts(obj.filepath); + error('NANSEN:MetaTable:ClassNotFound', ... + ['The MetaTable "%s" holds entries of class %s, which is not on the MATLAB path. ', ... + 'Add the folder that defines %s to the path and open the table again.'], ... + fileName, className, className) + end obj.fromStruct(S); end diff --git a/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m b/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m index f3b1b0f9d..60886665f 100644 --- a/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m +++ b/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m @@ -242,6 +242,22 @@ function removeSavePathFromTable(obj) end end + function tf = hasMasterMetaTable(obj, typeName) + %hasMasterMetaTable Check if the catalog has a master MetaTable of a type + % + % tf = hasMasterMetaTable(obj, typeName) is true when the class + % name of a master MetaTable contains typeName, ignoring case. + % This is the rule getMasterMetaTable uses to find the table, so + % a table of a project's own class, such as + % myproject.metadata.type.Subject, counts as a subject table. + if isempty(obj.Table) + tf = false; + return + end + tf = any(obj.Table.IsMaster & ... + contains(obj.Table.MetaTableClass, typeName, 'IgnoreCase', true)); + end + function metaTable = getMasterMetaTable(obj, typeName) % Todo: merge with method below (getMasterTable) isMatch = obj.Table.IsMaster & ... @@ -342,6 +358,12 @@ function registerMetaTable(obj, metaTable, options) end metaTable.save(true) + + % A table opened earlier from the same file may still be in the + % cache, and version numbers count saves of one file, so its + % number can equal the new file's and open would return it. + % The registered table replaces it. + nansen.metadata.MetaTableCache.instance().add(metaTable.filepath, metaTable) end function setDefaultMetaTable(obj, metaTable) diff --git a/code/apps/+nansen/@App/App.m b/code/apps/+nansen/@App/App.m index 177389e73..9d4fcac93 100644 --- a/code/apps/+nansen/@App/App.m +++ b/code/apps/+nansen/@App/App.m @@ -3699,9 +3699,10 @@ function setRequestFocusOnNextMetaTableRefresh(app, requestFocus) function tf = checkIfSubjectTableExists(~, metaTableCatalog) % Todo: should not be a nansen.App method, project level... - existingClasses = unique( metaTableCatalog.Table.MetaTableClass ); - % Todo: generalize, i.e are there subclasses (project specific subject definitions?) - tf = any(strcmp(existingClasses, 'nansen.metadata.type.Subject')); + % A project may define its own subject class. Such a table is + % the project's subject table, and creating another one would + % overwrite its file, which has the same default name. + tf = metaTableCatalog.hasMasterMetaTable('subject'); end end diff --git a/tests/+nansen/+unittest/+manage/+helper/LabeledSession.m b/tests/+nansen/+unittest/+manage/+helper/LabeledSession.m new file mode 100644 index 000000000..bfa56e390 --- /dev/null +++ b/tests/+nansen/+unittest/+manage/+helper/LabeledSession.m @@ -0,0 +1,17 @@ +classdef LabeledSession < nansen.metadata.type.Session +%LabeledSession - A session type with one property that nansen.metadata.type.Session lacks +% +% Tests use it as the class of a session table, to check that functions +% which create sessions for a table create them with the class of the +% table. + + properties (SetObservable) + Label char + end + + methods + function obj = LabeledSession(varargin) + obj@nansen.metadata.type.Session(varargin{:}) + end + end +end diff --git a/tests/+nansen/+unittest/+manage/+helper/LabeledSubject.m b/tests/+nansen/+unittest/+manage/+helper/LabeledSubject.m new file mode 100644 index 000000000..f7a57f8cc --- /dev/null +++ b/tests/+nansen/+unittest/+manage/+helper/LabeledSubject.m @@ -0,0 +1,10 @@ +classdef LabeledSubject < nansen.metadata.type.Subject +%LabeledSubject - A subject type with one property that nansen.metadata.type.Subject lacks +% +% Tests use it as the class of a subject table, to check that functions +% which add subjects create them with the class of the table. + + properties (SetObservable) + Label char + end +end diff --git a/tests/+nansen/+unittest/+manage/DetectNewSessionsTest.m b/tests/+nansen/+unittest/+manage/DetectNewSessionsTest.m new file mode 100644 index 000000000..c9b76a0df --- /dev/null +++ b/tests/+nansen/+unittest/+manage/DetectNewSessionsTest.m @@ -0,0 +1,80 @@ +classdef DetectNewSessionsTest < matlab.unittest.TestCase +%DetectNewSessionsTest - Tests for nansen.manage.detectNewSessions +% +% The data location of the mock project holds several sessions. Each +% test creates the master session table with a session class, removes +% one session from it, and checks what detectNewSessions finds. +% +% Run tests: +% runtests('nansen.unittest.manage.DetectNewSessionsTest') + + properties + Project + end + + methods (TestClassSetup) + function setupProject(testCase) + testCase.applyFixture(nansen.fixture.ProjectFixture) + testCase.Project = nansen.getCurrentProject(); + end + end + + methods (Test) + function testDetectsSessionMissingFromTable(testCase) + [sessionTable, removedSessionId] = testCase.createSessionTableWithoutFirstSession( ... + @nansen.metadata.type.Session); + + newSessions = nansen.manage.detectNewSessions(sessionTable, 'all'); + + testCase.verifyEqual(string({newSessions.sessionID}), removedSessionId) + end + + function testNewSessionsHaveClassOfSessionTable(testCase) + sessionTable = testCase.createSessionTableWithoutFirstSession( ... + @nansen.unittest.manage.helper.LabeledSession); + + newSessions = nansen.manage.detectNewSessions(sessionTable, 'all'); + + testCase.verifyClass(newSessions, 'nansen.unittest.manage.helper.LabeledSession') + end + + function testDetectedSessionsCanBeAddedToTable(testCase) + % The steps the app takes when the user detects new sessions + [sessionTable, removedSessionId] = testCase.createSessionTableWithoutFirstSession( ... + @nansen.unittest.manage.helper.LabeledSession); + + newSessions = nansen.manage.detectNewSessions(sessionTable, 'all'); + newSessionTable = nansen.metadata.MetaTable.new(newSessions); + testCase.Project.synchronizeMetaTableVariables(newSessionTable); + sessionTable.addTable(newSessionTable.entries) + + testCase.verifyTrue(ismember(removedSessionId, string(sessionTable.entries.sessionID))) + end + end + + methods (Access = private) + function [sessionTable, removedSessionId] = createSessionTableWithoutFirstSession(testCase, sessionConstructor) + %createSessionTableWithoutFirstSession - Replace the master session table with one that lacks a session + catalog = testCase.Project.MetaTableCatalog; + isSessionTable = catalog.Table.IsMaster & ... + contains(string(catalog.Table.MetaTableClass), "session", 'IgnoreCase', true); + for name = reshape(string(catalog.Table.MetaTableName(isSessionTable)), 1, []) + filePath = catalog.getMetaTableFilePath(char(name)); + catalog.removeEntry(char(name)) + if isfile(filePath) + nansen.metadata.MetaTableCache.instance().remove(filePath) + delete(filePath) + end + end + + % initializeSessionTable registers the table in a catalog it + % reads from disk, so the catalog is read again here + nansen.config.initializeSessionTable(testCase.Project.DataLocationModel, ... + sessionConstructor, SkipInteractiveSteps=true); + sessionTable = testCase.Project.MetaTableCatalog.getMasterMetaTable('session'); + removedSessionId = string(sessionTable.entries.sessionID{1}); + sessionTable.removeEntries(char(removedSessionId)) + sessionTable.save() + end + end +end diff --git a/tests/+nansen/+unittest/+manage/UpdateSubjectTableTest.m b/tests/+nansen/+unittest/+manage/UpdateSubjectTableTest.m new file mode 100644 index 000000000..436e265f5 --- /dev/null +++ b/tests/+nansen/+unittest/+manage/UpdateSubjectTableTest.m @@ -0,0 +1,73 @@ +classdef UpdateSubjectTableTest < matlab.unittest.TestCase +%UpdateSubjectTableTest - Tests for nansen.manage.updateSubjectTable +% +% The mock project has a session table whose sessions name several +% subjects. Each test creates the master subject table from those +% sessions, removes one subject from it, and checks what +% updateSubjectTable adds back. +% +% Run tests: +% runtests('nansen.unittest.manage.UpdateSubjectTableTest') + + properties + Catalog + SessionSubjectIds string + end + + methods (TestClassSetup) + function setupProject(testCase) + testCase.applyFixture(nansen.fixture.ProjectFixture) + testCase.Catalog = nansen.getCurrentProject().MetaTableCatalog; + sessionTable = testCase.Catalog.getMasterMetaTable('session'); + testCase.SessionSubjectIds = unique(string(sessionTable.entries.subjectID)); + testCase.assumeGreaterThan(numel(testCase.SessionSubjectIds), 1, ... + 'The mock project must have sessions of more than one subject.') + end + end + + methods (Test) + function testAddsEachMissingSubjectOnce(testCase) + subjectTable = testCase.createSubjectTableWithoutFirstSubject( ... + 'nansen.metadata.type.Subject'); + + nansen.manage.updateSubjectTable(testCase.Catalog) + + testCase.verifyEqual(sort(string(subjectTable.entries.SubjectID)), ... + testCase.SessionSubjectIds) + end + + function testNewSubjectsHaveClassOfSubjectTable(testCase) + subjectClassName = 'nansen.unittest.manage.helper.LabeledSubject'; + subjectTable = testCase.createSubjectTableWithoutFirstSubject(subjectClassName); + + nansen.manage.updateSubjectTable(testCase.Catalog) + + testCase.verifyEqual(sort(string(subjectTable.entries.SubjectID)), ... + testCase.SessionSubjectIds) + testCase.verifyTrue(ismember('Label', subjectTable.entries.Properties.VariableNames)) + testCase.verifyEqual(subjectTable.MetaTableClass, subjectClassName) + end + end + + methods (Access = private) + function subjectTable = createSubjectTableWithoutFirstSubject(testCase, subjectClassName) + %createSubjectTableWithoutFirstSubject - Replace the master subject table with one that lacks a subject + catalog = testCase.Catalog; + isSubjectTable = catalog.Table.IsMaster & ... + contains(string(catalog.Table.MetaTableClass), "subject", 'IgnoreCase', true); + for name = reshape(string(catalog.Table.MetaTableName(isSubjectTable)), 1, []) + filePath = catalog.getMetaTableFilePath(char(name)); + catalog.removeEntry(char(name)) + if isfile(filePath) + nansen.metadata.MetaTableCache.instance().remove(filePath) + delete(filePath) + end + end + + nansen.config.initializeSubjectTable(catalog, subjectClassName) + subjectTable = catalog.getMasterMetaTable('subject'); + subjectTable.removeEntries(char(testCase.SessionSubjectIds(1))) + subjectTable.save() + end + end +end diff --git a/tests/+nansen/+unittest/+metadata/MetaTableTest.m b/tests/+nansen/+unittest/+metadata/MetaTableTest.m index 0f73640ba..5ada1ad6b 100644 --- a/tests/+nansen/+unittest/+metadata/MetaTableTest.m +++ b/tests/+nansen/+unittest/+metadata/MetaTableTest.m @@ -419,6 +419,21 @@ function testSaveAndLoad(testCase) testCase.verifyEqual(mt2.members, testCase.TestMetaTable.members); end + function testOpenErrorsWhenClassIsNotOnPath(testCase) + testFilePath = testCase.saveTableWithClass('myproject.metadata.type.Session', ''); + + testCase.verifyError(@() nansen.metadata.MetaTable.open(testFilePath), ... + 'NANSEN:MetaTable:ClassNotFound') + end + + function testOpenWithIdVarnameNeedsNoClassOnPath(testCase) + testFilePath = testCase.saveTableWithClass('myproject.metadata.type.Session', 'sessionID'); + + metaTable = nansen.metadata.MetaTable.open(testFilePath); + + testCase.verifyEqual(height(metaTable.entries), height(testCase.TestEntries)) + end + function testOpenLoadsMetaTableFromFile(testCase) testCase.createTestMetaTable(); @@ -714,6 +729,56 @@ function testRegisterMetaTablePersistsNonDefaultCatalogEntry(testCase) testCase.verifyTrue(isfile(expectedFilePath)); end + function testHasMasterMetaTableIsFalseForEmptyCatalog(testCase) + catalogPath = fullfile(testCase.TestDir, 'metatable_catalog.mat'); + catalog = nansen.metadata.MetaTableCatalog(catalogPath); + + testCase.verifyFalse(catalog.hasMasterMetaTable('subject')) + end + + function testHasMasterMetaTableMatchesProjectSubjectClass(testCase) + % The class is only a name to the catalog, so it need not exist + catalogPath = fullfile(testCase.TestDir, 'metatable_catalog.mat'); + catalog = nansen.metadata.MetaTableCatalog(catalogPath); + metaTable = nansen.metadata.MetaTable(testCase.TestEntries, ... + 'MetaTableClass', 'myproject.metadata.type.Subject', ... + 'MetaTableIdVarname', 'sessionID'); + options = struct('MetaTableName', 'Subject', 'IsDefault', false, 'IsMaster', true); + catalog.registerMetaTable(metaTable, options); + + testCase.verifyTrue(catalog.hasMasterMetaTable('subject')) + testCase.verifyFalse(catalog.hasMasterMetaTable('session')) + end + + function testRegisterMetaTableReplacesCachedTableAtSamePath(testCase) + % Removing a master table and registering a new one under the + % same name writes the same file. The table opened before is + % still in the cache, and its version number can equal the + % new file's, so opening the file must return the new table. + catalogPath = fullfile(testCase.TestDir, 'metatable_catalog.mat'); + catalog = nansen.metadata.MetaTableCatalog(catalogPath); + options = struct('MetaTableName', 'ReplacedTable', 'IsDefault', false, 'IsMaster', true); + + firstTable = nansen.metadata.MetaTable(testCase.TestEntries, ... + 'MetaTableClass', 'table', 'MetaTableIdVarname', 'sessionID'); + catalog.registerMetaTable(firstTable, options); + filePath = catalog.getMetaTableFilePath('ReplacedTable'); + nansen.metadata.MetaTable.open(filePath); + + catalog.removeEntry('ReplacedTable'); + delete(filePath) + + newEntries = testCase.TestEntries; + newEntries.Value(:) = 777; + secondTable = nansen.metadata.MetaTable(newEntries, ... + 'MetaTableClass', 'table', 'MetaTableIdVarname', 'sessionID'); + catalog.registerMetaTable(secondTable, options); + + testCase.assertEqual(catalog.getMetaTableFilePath('ReplacedTable'), filePath) + reopened = nansen.metadata.MetaTable.open(filePath); + testCase.verifyEqual(reopened.entries.Value, newEntries.Value) + end + function testMetaTableCatalogIgnoresLegacySavePathOnLoad(testCase) catalogPath = fullfile(testCase.TestDir, 'metatable_catalog.mat'); catalog = nansen.metadata.MetaTableCatalog(catalogPath); @@ -1240,4 +1305,22 @@ function testNewWithTable(testCase) testCase.verifyEqual(height(mt.entries), testCase.NUM_TEST_ENTRIES); end end + + methods (Access = private) + function testFilePath = saveTableWithClass(testCase, className, idVarname) + % Save the test table, then set the class and ID column name + % stored in its file + testCase.createTestMetaTable(); + testFilePath = fullfile(testCase.TestDir, 'class_metatable.mat'); + testCase.TestMetaTable.setFilepath(testFilePath); + testCase.TempFiles{end+1} = testFilePath; + testCase.TestMetaTable.save(true); + + S = load(testFilePath); + S.MetaTableClass = className; + S.MetaTableIdVarname = idVarname; + save(testFilePath, '-struct', 'S'); + nansen.metadata.MetaTableCache.instance("reset"); + end + end end diff --git a/tests/+nansen/+unittest/+metadata/SessionDataLocationTest.m b/tests/+nansen/+unittest/+metadata/SessionDataLocationTest.m new file mode 100644 index 000000000..c9693a1d8 --- /dev/null +++ b/tests/+nansen/+unittest/+metadata/SessionDataLocationTest.m @@ -0,0 +1,62 @@ +classdef SessionDataLocationTest < matlab.unittest.TestCase + %SessionDataLocationTest Resolve a session's data locations against a data location model + % + % A new project has default data locations with no root path until + % they are configured. A session built while such a location exists + % must still resolve the locations that do have a root. + % + % Run tests: + % runtests('nansen.unittest.metadata.SessionDataLocationTest') + + properties + Model + SessionFolder char + end + + methods (TestClassSetup) + function setupProject(testCase) + testCase.applyFixture(nansen.fixture.ProjectFixture) + end + end + + methods (TestMethodSetup) + function createModel(testCase) + import matlab.unittest.fixtures.TemporaryFolderFixture + root = testCase.applyFixture(TemporaryFolderFixture).Folder; + + dataFolder = fullfile(root, 'data'); + testCase.SessionFolder = fullfile(dataFolder, 'session1'); + mkdir(testCase.SessionFolder) + + testCase.Model = nansen.config.dloc.DataLocationModel( ... + fullfile(root, 'datalocation_settings.mat')); + + configured = nansen.config.dloc.DataLocationModel.getBlankItem(); + configured.Name = 'Raw'; + configured.Type = nansen.config.dloc.DataLocationType('recorded'); + configured.RootPath = struct('Key', nansen.util.getuuid(), 'Value', dataFolder, ... + 'DiskName', '', 'DiskType', 'Local'); + testCase.Model.addDataLocation(configured) + + unconfigured = nansen.config.dloc.DataLocationModel.getBlankItem(); + unconfigured.Name = 'Processed'; + unconfigured.Type = nansen.config.dloc.DataLocationType('processed'); + testCase.Model.addDataLocation(unconfigured) + end + end + + methods (Test) + function testLocationWithoutRootPathDoesNotStopSession(testCase) + testCase.assertEmpty(testCase.Model.getDataLocation('Processed').RootPath) + + session = nansen.metadata.type.Session( ... + struct('Raw', testCase.SessionFolder, 'Processed', ''), ... + 'DataLocationModel', testCase.Model); + + raw = session.DataLocation(strcmp({session.DataLocation.Name}, 'Raw')); + testCase.verifyEqual(raw.RootUid, testCase.Model.getDataLocation('Raw').RootPath.Key) + processed = session.DataLocation(strcmp({session.DataLocation.Name}, 'Processed')); + testCase.verifyEmpty(processed.RootUid) + end + end +end