From 44e8de9432fd92df8134e048500a9c6a271c6793 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 18 Sep 2026 18:57:49 +0200 Subject: [PATCH 1/6] fix(session): skip data locations without a root path when resolving roots A session resolves each of its data locations against the data location model and, when it has no root yet, takes the last root path of the model's item. A new project's default data locations have no root path until they are configured, so building a session in such a project failed with "Array indices must be positive integers or logical values" (seen when importing a Dataset Structure Model into a new project and creating its session table before configuring the default Processed location). A data location without a root path now leaves the session's RootUid empty, as for a session that has no folder in that location. Co-Authored-By: Claude Opus 5 --- code/+nansen/+metadata/+type/Session.m | 6 +- .../+metadata/SessionDataLocationTest.m | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/+nansen/+unittest/+metadata/SessionDataLocationTest.m diff --git a/code/+nansen/+metadata/+type/Session.m b/code/+nansen/+metadata/+type/Session.m index ddd58b93..bc061111 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/tests/+nansen/+unittest/+metadata/SessionDataLocationTest.m b/tests/+nansen/+unittest/+metadata/SessionDataLocationTest.m new file mode 100644 index 00000000..c9693a1d --- /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 From bd1831987366f8f1b7283732f0aeee49b7410808 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 18 Sep 2026 20:59:37 +0200 Subject: [PATCH 2/6] fix(metatable): let a registered table replace a stale cached one MetaTable.open returns the cached table for a file when its in-memory version number equals the one in the file. Version numbers count the saves of one file, so after a master table is removed and a new one is registered under the same name, the new file can have the same number as the table opened before, and open returned the old table. A caller that then edited and saved it wrote the old entries over the new file (seen when re-importing a Dataset Structure Model into a project). registerMetaTable now puts the table it registers into the cache, replacing whatever was cached for that file. Co-Authored-By: Claude Opus 5 --- .../@MetaTableCatalog/MetaTableCatalog.m | 6 ++++ .../+unittest/+metadata/MetaTableTest.m | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m b/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m index f3b1b0f9..f968f9c5 100644 --- a/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m +++ b/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m @@ -342,6 +342,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/tests/+nansen/+unittest/+metadata/MetaTableTest.m b/tests/+nansen/+unittest/+metadata/MetaTableTest.m index 0f73640b..a86ed401 100644 --- a/tests/+nansen/+unittest/+metadata/MetaTableTest.m +++ b/tests/+nansen/+unittest/+metadata/MetaTableTest.m @@ -714,6 +714,35 @@ function testRegisterMetaTablePersistsNonDefaultCatalogEntry(testCase) testCase.verifyTrue(isfile(expectedFilePath)); 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); From 2b1041d0f93ef4c26aeb70e09e28e62ac4b0ef0a Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 18 Sep 2026 21:15:24 +0200 Subject: [PATCH 3/6] fix(manage): create new subjects with the subject table's class updateSubjectTable created the subjects it adds as nansen.metadata.type.Subject unless a class was passed. When the master subject table holds a subclass with more properties, the new rows lack those columns and MetaTable.addTable fails in vertcat. The app calls updateSubjectTable after detecting sessions, so detection failed in such projects. The default class is now the MetaTableClass of the subject table. The loop that sets subject IDs also runs over the new IDs only, instead of over every ID in the session table. Co-Authored-By: Claude Opus 5 --- code/+nansen/+manage/updateSubjectTable.m | 21 ++++-- .../+manage/+helper/LabeledSubject.m | 10 +++ .../+manage/UpdateSubjectTableTest.m | 73 +++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 tests/+nansen/+unittest/+manage/+helper/LabeledSubject.m create mode 100644 tests/+nansen/+unittest/+manage/UpdateSubjectTableTest.m diff --git a/code/+nansen/+manage/updateSubjectTable.m b/code/+nansen/+manage/updateSubjectTable.m index 1ad47974..183e77ef 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/tests/+nansen/+unittest/+manage/+helper/LabeledSubject.m b/tests/+nansen/+unittest/+manage/+helper/LabeledSubject.m new file mode 100644 index 00000000..f7a57f8c --- /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/UpdateSubjectTableTest.m b/tests/+nansen/+unittest/+manage/UpdateSubjectTableTest.m new file mode 100644 index 00000000..436e265f --- /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 From 4762e2585b160211a0ee8107ae8f251c043183df Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 18 Sep 2026 21:22:27 +0200 Subject: [PATCH 4/6] fix(manage): detect new sessions with the session table's class detectNewSessions created every session it detects as nansen.metadata.type.Session. When the session table holds a subclass with more properties, the app could not add the detected sessions to the table: MetaTable.addTable failed in vertcat because the new rows lack the subclass columns. The sessions are now created with the MetaTableClass of the table that is passed in. Co-Authored-By: Claude Opus 5 --- code/+nansen/+manage/detectNewSessions.m | 8 +- .../+manage/+helper/LabeledSession.m | 17 ++++ .../+unittest/+manage/DetectNewSessionsTest.m | 80 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 tests/+nansen/+unittest/+manage/+helper/LabeledSession.m create mode 100644 tests/+nansen/+unittest/+manage/DetectNewSessionsTest.m diff --git a/code/+nansen/+manage/detectNewSessions.m b/code/+nansen/+manage/detectNewSessions.m index e347018c..d7d21fec 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/tests/+nansen/+unittest/+manage/+helper/LabeledSession.m b/tests/+nansen/+unittest/+manage/+helper/LabeledSession.m new file mode 100644 index 00000000..bfa56e39 --- /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/DetectNewSessionsTest.m b/tests/+nansen/+unittest/+manage/DetectNewSessionsTest.m new file mode 100644 index 00000000..c9b76a0d --- /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 From 4f40885f1611abe29031ccceeb23e167b56f081f Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 18 Sep 2026 21:32:48 +0200 Subject: [PATCH 5/6] fix(app): recognize a subject table of a project's own subject class On start, the app creates a subject table when checkIfSubjectTableExists returns false. The check compared class names with 'nansen.metadata.type.Subject' exactly, so a project whose subject table has a subclass got a new table on every start. Both tables have the default name "Subject" and the same file name, so the new table replaced the project's own. MetaTableCatalog.hasMasterMetaTable(typeName) matches master tables by the rule getMasterMetaTable uses, a case-insensitive substring of the class name, and the app's check calls it. Co-Authored-By: Claude Opus 5 --- .../@MetaTableCatalog/MetaTableCatalog.m | 16 ++++++++++++++ code/apps/+nansen/@App/App.m | 7 ++++--- .../+unittest/+metadata/MetaTableTest.m | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m b/code/+nansen/+metadata/@MetaTableCatalog/MetaTableCatalog.m index f968f9c5..60886665 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 & ... diff --git a/code/apps/+nansen/@App/App.m b/code/apps/+nansen/@App/App.m index 177389e7..9d4fcac9 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/+metadata/MetaTableTest.m b/tests/+nansen/+unittest/+metadata/MetaTableTest.m index a86ed401..e2d6bb1b 100644 --- a/tests/+nansen/+unittest/+metadata/MetaTableTest.m +++ b/tests/+nansen/+unittest/+metadata/MetaTableTest.m @@ -714,6 +714,27 @@ 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 From 122c6abac3505bd9840bf445c65011437d80efe9 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 18 Sep 2026 21:37:58 +0200 Subject: [PATCH 6/6] fix(metatable): name the missing class when a table's class is not on the path A MetaTable file saved without MetaTableIdVarname gets the name of its ID column from the IDNAME constant of its class. When that class is not on the path, SchemaIdName fell back to 'id', and loading failed with "Unrecognized table variable name 'id'". Loading such a file now fails with NANSEN:MetaTable:ClassNotFound, which names the class and says to add it to the path. Files that store MetaTableIdVarname, and tables of class 'table', load as before. Co-Authored-By: Claude Opus 5 --- code/+nansen/+metadata/@MetaTable/MetaTable.m | 13 ++++++++ .../+unittest/+metadata/MetaTableTest.m | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/code/+nansen/+metadata/@MetaTable/MetaTable.m b/code/+nansen/+metadata/@MetaTable/MetaTable.m index 7f0cfbca..b5b1b460 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/tests/+nansen/+unittest/+metadata/MetaTableTest.m b/tests/+nansen/+unittest/+metadata/MetaTableTest.m index e2d6bb1b..5ada1ad6 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(); @@ -1290,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