diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg
index 367e4cc71..b6eedcd61 100644
--- a/.github/badges/code_issues.svg
+++ b/.github/badges/code_issues.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg
index 13f7d3cb6..57016f9d6 100644
--- a/.github/badges/tests.svg
+++ b/.github/badges/tests.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/code/+nansen/+config/+project/@Project/Project.m b/code/+nansen/+config/+project/@Project/Project.m
index 4635bb6ac..5783ee9c2 100644
--- a/code/+nansen/+config/+project/@Project/Project.m
+++ b/code/+nansen/+config/+project/@Project/Project.m
@@ -28,7 +28,8 @@
end
properties (SetAccess = private)
- FolderPath char % Path to the project folder
+ FolderPath char % Path to the project folder
+ FileAdapterRegistry nansen.plugin.fileadapter.Registry % Registry for FileAdapter plugins
end
properties (SetAccess = private, Hidden)
@@ -89,6 +90,7 @@
obj.FolderPath = projectFolder;
obj.initializeModules()
+ obj.initializeFileAdapterRegistry()
end
end
@@ -666,8 +668,9 @@ function updateModules(obj)
% Remove any modules that were included but not any more
for i = numel(removeIdx):-1:1
- % removedModule = obj.IncludedModules(removeIdx(i));
+ removedId = string(currentModules(removeIdx(i)).ID);
obj.IncludedModules(removeIdx(i)) = [];
+ obj.FileAdapterRegistry.removeSource(removedId);
% Remove variables (Not needed?):
%variableList = removedModule.DataVariables;
@@ -682,10 +685,49 @@ function updateModules(obj)
% Update variable model based on module's template variables
variableList = table2struct( module.getTable('DataVariables') );
obj.VariableModel.addDataVariableSet(variableList)
+
+ moduleRoot = fileparts(module.getFileAdapterFolder());
+ obj.FileAdapterRegistry.addSource(string(module.ID), moduleRoot);
+ end
+
+ if ~isempty(addedModuleID) || ~isempty(removeIdx)
+ obj.FileAdapterRegistry.reconcile();
+ notify(obj, 'ModuleListChanged')
end
end
end
+ function initializeFileAdapterRegistry(obj)
+ %initializeFileAdapterRegistry Build and populate the FileAdapter registry.
+ %
+ % Pinned source ordering (highest to lowest priority):
+ % top - project's own +fileadapter/ folder
+ % middle - included optional modules (in IncludedModules order)
+ % bottom - core module
+
+ obj.FileAdapterRegistry = nansen.plugin.fileadapter.Registry();
+
+ coreModuleName = string(obj.RequiredModuleName);
+ coreRoot = fullfile(nansen.common.constant.ModuleRootDirectory, ...
+ utility.path.packagename2pathstr(coreModuleName));
+ obj.FileAdapterRegistry.pinBottom(coreModuleName, coreRoot);
+
+ % Middle: included modules (excluding the required/core module which
+ % is already pinned at the bottom).
+ for i = 1:numel(obj.IncludedModules)
+ module = obj.IncludedModules(i);
+ moduleId = string(module.ID);
+ if moduleId == coreModuleName; continue; end
+ moduleRoot = fileparts(module.getFileAdapterFolder());
+ obj.FileAdapterRegistry.addSource(moduleId, moduleRoot);
+ end
+
+ projectModuleRoot = obj.getModuleFolder();
+ obj.FileAdapterRegistry.pinTop(string(obj.ID), projectModuleRoot);
+
+ obj.FileAdapterRegistry.reconcile();
+ end
+
function initializeVariableModel(obj)
% initializeVariableModel - Initialize a variable model
filePath = obj.getCatalogPath('VariableModel');
diff --git a/code/+nansen/+config/+varmodel/VariableModel.m b/code/+nansen/+config/+varmodel/VariableModel.m
index 83ebbe181..6a5fa8a19 100644
--- a/code/+nansen/+config/+varmodel/VariableModel.m
+++ b/code/+nansen/+config/+varmodel/VariableModel.m
@@ -175,7 +175,7 @@ function updateDefaultValues(obj)
[obj.Data(:).DataType] = deal('');
for i = 1:numel(obj.Data)
if ~strcmp(obj.Data(i).FileAdapter, 'Default')
- isMatch = strcmp({fileAdapterList.FileAdapterName}, obj.Data(i).FileAdapter);
+ isMatch = [fileAdapterList.FileAdapterName] == string(obj.Data(i).FileAdapter);
if any(isMatch)
fileAdapterFcn = str2func(fileAdapterList(isMatch).FunctionName);
obj.Data(i).DataType = fileAdapterFcn().DataType;
@@ -300,7 +300,7 @@ function updateDefaultValues(obj)
end
% Find file adapter match for name
- isMatch = strcmp({fileAdapterList.FileAdapterName}, variableInfo.FileAdapter);
+ isMatch = [fileAdapterList.FileAdapterName] == string(variableInfo.FileAdapter);
if ~any(isMatch)
error('File adapter was not found')
@@ -642,7 +642,7 @@ function disableNotifications(obj)
function variableItem = updateVariableDataType(variableItem)
fileAdapterList = nansen.dataio.listFileAdapters();
if ~strcmp(variableItem.FileAdapter, 'Default')
- isMatch = strcmp({fileAdapterList.FileAdapterName}, variableItem.FileAdapter);
+ isMatch = [fileAdapterList.FileAdapterName] == string(variableItem.FileAdapter);
if any(isMatch)
fileAdapterFcn = str2func(fileAdapterList(isMatch).FunctionName);
diff --git a/code/+nansen/+config/+varmodel/VariableModelUI.m b/code/+nansen/+config/+varmodel/VariableModelUI.m
index 2d426a7e5..8c0d0c7bc 100644
--- a/code/+nansen/+config/+varmodel/VariableModelUI.m
+++ b/code/+nansen/+config/+varmodel/VariableModelUI.m
@@ -115,7 +115,7 @@ function delete(obj)
S(j).Subfolder = hRow.SubfolderName.Value;
% Update data type based on fileadapter selection
- isMatch = strcmp({fileAdapterList.FileAdapterName}, S(j).FileAdapter);
+ isMatch = [fileAdapterList.FileAdapterName] == string(S(j).FileAdapter);
if any(isMatch) && ~strcmp( S(j).FileAdapter, 'Default' )
S(j).DataType = fileAdapterList(isMatch).DataType;
end
@@ -297,9 +297,9 @@ function assignDefaultTablePropertyValues(obj)
if ~isempty(rowData.FileType)
fileAdapterOptions = nansen.dataio.listFileAdapters(rowData.FileType);
- fileAdapterOptions = {fileAdapterOptions.FileAdapterName};
+ fileAdapterOptions = [fileAdapterOptions.FileAdapterName];
else
- fileAdapterOptions = {obj.FileAdapterList.FileAdapterName};
+ fileAdapterOptions = [obj.FileAdapterList.FileAdapterName];
end
hRow.FileAdapterSelect.Items = fileAdapterOptions;
@@ -446,18 +446,18 @@ function onFileTypeChanged(obj, src, ~)
fileType = lower( strrep(fileType, '.', '') );
fileAdapterOptions = nansen.dataio.listFileAdapters(fileType);
- fileAdapterOptions = {fileAdapterOptions.FileAdapterName};
+ fileAdapterOptions = [fileAdapterOptions.FileAdapterName];
% Update the list of file adapters available for this filetype
- if ~isequal(fileAdapterOptions, {'N/A'})
+ if ~isequal(fileAdapterOptions, "N/A")
hRow.FileAdapterSelect.Items = fileAdapterOptions;
if ~contains(hRow.FileAdapterSelect.Value, fileAdapterOptions)
- hRow.FileAdapterSelect.Value = fileAdapterOptions{1};
+ hRow.FileAdapterSelect.Value = fileAdapterOptions(1);
end
else
hRow.FileAdapterSelect.Items = fileAdapterOptions;
- hRow.FileAdapterSelect.Value = fileAdapterOptions{1};
+ hRow.FileAdapterSelect.Value = fileAdapterOptions(1);
end
end
@@ -477,13 +477,14 @@ function onFileAdapterChanged(obj, src, evt)
newValue = evt.Value;
fileAdapterList = obj.FileAdapterList;
- isMatch = strcmp({fileAdapterList.FileAdapterName}, newValue);
+ isMatch = [fileAdapterList.FileAdapterName] == string(newValue);
isSupported = false;
if any(isMatch)
supportedFileTypes = fileAdapterList(isMatch).SupportedFileTypes;
- if any(ismember(supportedFileTypes, {fileType, ['.' fileType]}))
+ supportedFileTypesWithDot = "." + strrep(supportedFileTypes, ".", "");
+ if any(ismember(supportedFileTypesWithDot, "." + string(fileType)))
isSupported = true;
end
end
@@ -492,7 +493,8 @@ function onFileAdapterChanged(obj, src, evt)
if ~isSupported
hFig = ancestor(obj.Parent, 'figure');
if any(isMatch)
- allowedFileTypes = strcat('.', fileAdapterList(isMatch).SupportedFileTypes);
+ allowedFileTypes = fileAdapterList(isMatch).SupportedFileTypes;
+ allowedFileTypes = "." + strrep(allowedFileTypes, ".", "");
supportedFileTypes = strjoin(allowedFileTypes, ', ');
else
if strcmp(newValue, 'Default') % Todo: Should not "Default" be part of FileAdapterList?
diff --git a/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m b/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m
index 6e19e89e5..f52f383d0 100644
--- a/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m
+++ b/code/+nansen/+config/+varmodel/uiCreateDataVariableFromFile.m
@@ -40,8 +40,8 @@
S = struct();
S.VariableName = '';
S.FileNameExpression = fileName;
- S.FileAdapter = fileAdapterList(1).FileAdapterName;
- S.FileAdapter_ = {fileAdapterList.FileAdapterName};
+ S.FileAdapter = char(fileAdapterList(1).FileAdapterName);
+ S.FileAdapter_ = cellstr([fileAdapterList.FileAdapterName]);
S.Favorite = false;
if ~isempty(options.SkipFields)
@@ -92,7 +92,7 @@
end
% Get data type from file adapter
- fileAdapterIdx = strcmp({fileAdapterList.FileAdapterName}, S.FileAdapter);
+ fileAdapterIdx = [fileAdapterList.FileAdapterName] == string(S.FileAdapter);
% Normalize to char. Todo: Should support string type
varItem.DataType = char( fileAdapterList(fileAdapterIdx).DataType );
diff --git a/code/+nansen/+dataio/listFileAdapters.m b/code/+nansen/+dataio/listFileAdapters.m
index 9c4be6336..601b2563f 100644
--- a/code/+nansen/+dataio/listFileAdapters.m
+++ b/code/+nansen/+dataio/listFileAdapters.m
@@ -1,43 +1,39 @@
-function fileAdapterList = listFileAdapters(fileExtension, refresh)
-%listFileAdapters Create a list of file adapters
+function fileAdapterList = listFileAdapters(fileExtension, options)
+%listFileAdapters Return a struct array of available file adapters.
%
-% fileAdapterList = nansen.dataio.listFileAdapters() returns a struct
-% array containing information about file adapters.
+% fileAdapterList = nansen.dataio.listFileAdapters() returns all adapters
+% visible to the current project.
%
-% The fileAdapterList struct array contains the following fields:
-% FileAdapterName (char) : Name of fileadapter
-% FunctionName (char) : Name of function for file adapter
-% SupportedFileTypes (cell) : File types that are supported with this fileadapter
-% DataType (char) : Name of datatype returned by this file adapter
-
- % Todo: Ignore file adapters with a name that are already in the list
- % Todo: Start adding from project dir, then watchfolder, then internal?
-
- if nargin < 2 || isempty(refresh); refresh = false; end
-
- project = nansen.getCurrentProject();
- if isempty(project); fileAdapterList = struct.empty; return; end
-
- fileAdapterList = table2struct(project.getTable('FileAdapter', refresh));
+% fileAdapterList = nansen.dataio.listFileAdapters(fileExtension) filters
+% to adapters that support the given extension (with or without a dot).
+%
+% Each element of fileAdapterList has fields:
+% FileAdapterName (string) : Class name of the adapter
+% FunctionName (string) : Full MATLAB function/package name
+% SupportedFileTypes (string) : Supported file extensions
+% DataType (string) : Data type returned on load
+% IsDynamic (logical) : true for sidecar-based adapters
+%
+% Optional name-value arguments:
+% Project - Project instance to query (defaults to current project)
- if nargin < 1; fileExtension = ''; end
- if ~isempty(fileExtension); fileExtension = strrep(fileExtension, '.', ''); end
+ arguments
+ fileExtension (1,1) string = ""
+ options.Project = nansen.getCurrentProject()
+ end
- if ~isempty(fileExtension)
- validationFcn = @(extList) any(contains(extList, fileExtension, "IgnoreCase", true));
- keep = arrayfun(@(s) validationFcn(s.SupportedFileTypes), ...
- fileAdapterList);
- else
- keep = true(1, numel(fileAdapterList));
+ if isempty(options.Project)
+ fileAdapterList = struct.empty;
+ return
end
- fileAdapterList = fileAdapterList(keep);
+ fileAdapterList = options.Project.FileAdapterRegistry.list(fileExtension);
if isempty(fileAdapterList)
- fileAdapterList(1).FileAdapterName = 'N/A';
- fileAdapterList(1).FunctionName = '';
- fileAdapterList(1).SupportedFileTypes = {};
- fileAdapterList(1).DataType = '';
+ fileAdapterList(1).FileAdapterName = "N/A";
+ fileAdapterList(1).FunctionName = "";
+ fileAdapterList(1).SupportedFileTypes = strings(1, 0);
+ fileAdapterList(1).DataType = "";
end
if ~nargout
@@ -46,7 +42,7 @@
fileAdapterList.FunctionName = string(fileAdapterList.FunctionName);
fileAdapterList.DataType = string(fileAdapterList.DataType);
fileFormats = fileAdapterList.SupportedFileTypes;
- fileFormats = cellfun(@(c) string(strjoin(c, ', ')), fileFormats);
+ fileFormats = cellfun(@(c) strjoin(string(c), ', '), fileFormats);
fileAdapterList.SupportedFileTypes = fileFormats;
end
end
diff --git a/code/+nansen/+metadata/+type/Session.m b/code/+nansen/+metadata/+type/Session.m
index 1ac5aac8d..c4374c13e 100644
--- a/code/+nansen/+metadata/+type/Session.m
+++ b/code/+nansen/+metadata/+type/Session.m
@@ -692,7 +692,7 @@ function updateSessionFolder(obj, dataLocationName, folderPath) %#ok
end
% Get file adapter % Todo: make this more persistent...
- isMatch = strcmp({fileAdapterList.FileAdapterName}, variableInfo.FileAdapter);
+ isMatch = [fileAdapterList.FileAdapterName] == string(variableInfo.FileAdapter);
if ~any(isMatch)
error('NANSEN:Session:FileAdapterNotFound', 'File adapter was not found')
diff --git a/code/+nansen/+module/Module.m b/code/+nansen/+module/Module.m
index a04cdf335..8cdc0c34a 100644
--- a/code/+nansen/+module/Module.m
+++ b/code/+nansen/+module/Module.m
@@ -143,6 +143,11 @@
itemType = validatestring(itemType, {'SessionMethod', ...
'TableVariable', 'FileAdapter', 'DataVariables', ...
'DataLocations'}, 1);
+ if strcmp(itemType, 'FileAdapter')
+ warning('NANSEN:DeprecatedPath:ModuleFileAdapters', ...
+ ['Module.getTable(''FileAdapter'') is deprecated. ', ...
+ 'Use project.FileAdapterRegistry.list() instead.'])
+ end
itemTable = obj.rehash(itemType, forceRefresh);
end
@@ -163,6 +168,9 @@
end
function fileAdapterList = get.FileAdapters(obj)
+ warning('NANSEN:DeprecatedPath:ModuleFileAdapters', ...
+ ['Module.FileAdapters is deprecated. ', ...
+ 'Use project.FileAdapterRegistry.list() instead.'])
itemTable = obj.rehash('FileAdapter');
fileAdapterList = itemTable.FileAdapterName;
fileAdapterList = string(fileAdapterList)';
diff --git a/code/+nansen/+plugin/+base/PluginEventData.m b/code/+nansen/+plugin/+base/PluginEventData.m
new file mode 100644
index 000000000..b8a177f9c
--- /dev/null
+++ b/code/+nansen/+plugin/+base/PluginEventData.m
@@ -0,0 +1,23 @@
+classdef PluginEventData < event.EventData
+%PluginEventData Payload for registry plugin events.
+%
+% Carries the affected entry (for PluginAdded and PluginChanged events)
+% and the plugin ID (for PluginRemoved events). Either field may be
+% empty depending on the event type.
+
+ properties (SetAccess = immutable)
+ Entry % Struct with parsed adapter fields (Added / Changed events)
+ PluginId (1,1) string = "" % Adapter name id (Removed events)
+ end
+
+ methods
+ function obj = PluginEventData(entry, pluginId)
+ arguments
+ entry struct = struct.empty
+ pluginId (1,1) string = ""
+ end
+ obj.Entry = entry;
+ obj.PluginId = pluginId;
+ end
+ end
+end
diff --git a/code/+nansen/+plugin/+base/Registry.m b/code/+nansen/+plugin/+base/Registry.m
new file mode 100644
index 000000000..3be56705a
--- /dev/null
+++ b/code/+nansen/+plugin/+base/Registry.m
@@ -0,0 +1,251 @@
+classdef (Abstract) Registry < handle
+%Registry Abstract base class for project-owned plugin registries.
+%
+% Stores plugin entries from multiple sources with three precedence tiers:
+% - Top (single pinned slot, highest priority)
+% - Middle (ordered list of sources, appended with addSource)
+% - Bottom (single pinned slot, lowest priority)
+%
+% Subclasses implement scanSource (what to parse) and primaryId (which
+% field of the returned entry struct is the unique identifier).
+%
+% Usage:
+% reg = MyConcreteRegistry();
+% reg.pinBottom("core", "/path/to/core");
+% reg.addSource("mymod", "/path/to/module");
+% reg.pinTop("project", "/path/to/project");
+% reg.reconcile();
+% entries = reg.list();
+
+ events
+ PluginAdded
+ PluginRemoved
+ PluginChanged
+ PluginRegistryReconciled
+ end
+
+ properties (Access = private)
+ % Three-tier source storage
+ TopSource struct % scalar or empty struct with fields: id, path
+ MiddleSources struct % struct array with fields: id, path
+ BottomSource struct % scalar or empty struct with fields: id, path
+
+ % Resolved entry list after last reconcile (struct array, subclass-defined fields)
+ Entries struct
+ end
+
+ methods % Constructor
+ function obj = Registry()
+ obj.TopSource = struct('id', {}, 'path', {});
+ obj.MiddleSources = struct('id', {}, 'path', {});
+ obj.BottomSource = struct('id', {}, 'path', {});
+ obj.Entries = struct.empty;
+ end
+ end
+
+ methods % Source management API
+ function pinTop(obj, id, path)
+ %pinTop Set or replace the top-priority source slot.
+ %
+ % pinTop(id, path) assigns the top slot. If a different source is
+ % already pinned at the top it is silently replaced.
+ arguments
+ obj
+ id (1,1) string
+ path (1,1) string
+ end
+ obj.TopSource = struct('id', id, 'path', path);
+ end
+
+ function pinBottom(obj, id, path)
+ %pinBottom Set or replace the bottom-priority source slot.
+ %
+ % pinBottom(id, path) assigns the bottom slot. If a different source
+ % is already pinned at the bottom it is silently replaced.
+ arguments
+ obj
+ id (1,1) string
+ path (1,1) string
+ end
+ obj.BottomSource = struct('id', id, 'path', path);
+ end
+
+ function addSource(obj, id, path)
+ %addSource Append a source to the middle tier.
+ %
+ % Errors if the id is already present in any slot.
+ arguments
+ obj
+ id (1,1) string
+ path (1,1) string
+ end
+ if obj.hasSourceId(id)
+ error('NANSEN:Registry:DuplicateSourceId', ...
+ 'A source with id "%s" is already registered.', id)
+ end
+ obj.MiddleSources(end+1) = struct('id', id, 'path', path);
+ end
+
+ function removeSource(obj, id)
+ %removeSource Remove a source by id from any slot.
+ %
+ % Silently ignores unknown ids.
+ arguments
+ obj
+ id (1,1) string
+ end
+ if ~isempty(obj.TopSource) && obj.TopSource.id == id
+ obj.TopSource = struct('id', {}, 'path', {}); return
+ end
+ if ~isempty(obj.BottomSource) && obj.BottomSource.id == id
+ obj.BottomSource = struct('id', {}, 'path', {}); return
+ end
+ if ~isempty(obj.MiddleSources)
+ keep = [obj.MiddleSources.id] ~= id;
+ obj.MiddleSources = obj.MiddleSources(keep);
+ end
+ end
+
+ function reconcile(obj)
+ %reconcile Rescan all sources, diff against current entries, emit events.
+ %
+ % Fires PluginAdded, PluginRemoved, or PluginChanged for each
+ % changed entry, then fires PluginRegistryReconciled once at the end.
+ sources = obj.orderedSources();
+ newEntries = obj.buildEntries(sources);
+
+ oldIds = obj.entryIds(obj.Entries);
+ newIds = obj.entryIds(newEntries);
+
+ removedIds = setdiff(oldIds, newIds);
+ for i = 1:numel(removedIds)
+ evtData = nansen.plugin.base.PluginEventData( ...
+ struct.empty, removedIds(i));
+ notify(obj, 'PluginRemoved', evtData);
+ end
+
+ addedIds = setdiff(newIds, oldIds);
+ for i = 1:numel(addedIds)
+ entry = obj.findEntry(newEntries, addedIds(i));
+ evtData = nansen.plugin.base.PluginEventData(entry, addedIds(i));
+ notify(obj, 'PluginAdded', evtData);
+ end
+
+ sharedIds = intersect(oldIds, newIds);
+ for i = 1:numel(sharedIds)
+ oldEntry = obj.findEntry(obj.Entries, sharedIds(i));
+ newEntry = obj.findEntry(newEntries, sharedIds(i));
+ if ~isequal(oldEntry, newEntry)
+ evtData = nansen.plugin.base.PluginEventData(newEntry, sharedIds(i));
+ notify(obj, 'PluginChanged', evtData);
+ end
+ end
+
+ obj.Entries = newEntries;
+ notify(obj, 'PluginRegistryReconciled');
+ end
+ end
+
+ methods % Read API
+ function entries = list(obj)
+ %list Return the resolved entry struct array.
+ entries = obj.Entries;
+ end
+
+ function entry = get(obj, id)
+ %get Return the entry with the given id, or empty struct if not found.
+ entry = obj.findEntry(obj.Entries, id);
+ end
+
+ function entry = resolve(obj, id)
+ %resolve Return the highest-priority entry with the given id.
+ %
+ % Equivalent to get() for registries that already deduplicate by id
+ % during reconcile.
+ entry = obj.get(id);
+ end
+ end
+
+ methods (Abstract, Access = protected)
+ entries = scanSource(obj, sourceId, sourcePath)
+ %scanSource Scan one source directory and return its entries.
+ %
+ % Subclass defines the entry struct fields and parsing logic.
+
+ id = primaryId(obj, entry)
+ %primaryId Return the unique id string for an entry.
+ %
+ % Subclass returns the field of the entry struct that uniquely
+ % identifies the plugin (e.g. string(entry.FileAdapterName)).
+ end
+
+ methods (Access = private)
+ function sources = orderedSources(obj)
+ %orderedSources Return all sources in precedence order (top > middle > bottom).
+ sources = [obj.TopSource, obj.MiddleSources, obj.BottomSource];
+ end
+
+ function allEntries = buildEntries(obj, sources)
+ %buildEntries Scan all sources and merge with precedence dedup.
+ allEntries = []; % Becomes a struct array on first assignment
+ seenIds = string.empty;
+
+ for i = 1:numel(sources)
+ raw = obj.scanSource(sources(i).id, sources(i).path);
+ if isempty(raw); continue; end
+
+ for j = 1:numel(raw)
+ entryId = obj.primaryId(raw(j));
+ if ~ismember(entryId, seenIds)
+ seenIds(end+1) = entryId; %#ok
+ if isempty(allEntries)
+ allEntries = raw(j);
+ else
+ allEntries(end+1) = raw(j); %#ok
+ end
+ end
+ % Higher-priority (earlier) source wins; lower-priority entries
+ % are silently dropped.
+ end
+ end
+
+ if isempty(allEntries)
+ allEntries = struct.empty;
+ end
+ end
+
+ function tf = hasSourceId(obj, id)
+ %hasSourceId Return true if id is already registered in any slot.
+ tf = false;
+ if ~isempty(obj.TopSource) && obj.TopSource.id == id
+ tf = true; return
+ end
+ if ~isempty(obj.BottomSource) && obj.BottomSource.id == id
+ tf = true; return
+ end
+ if ~isempty(obj.MiddleSources) && any([obj.MiddleSources.id] == id)
+ tf = true;
+ end
+ end
+
+ function ids = entryIds(obj, entries)
+ %entryIds Return a string array of all entry ids.
+ if isempty(entries)
+ ids = string.empty;
+ else
+ ids = arrayfun(@(e) obj.primaryId(e), entries);
+ end
+ end
+
+ function entry = findEntry(obj, entries, id)
+ %findEntry Find an entry by id; returns empty struct if not found.
+ entry = struct.empty;
+ if isempty(entries); return; end
+ ids = obj.entryIds(entries);
+ idx = find(ids == id, 1);
+ if ~isempty(idx)
+ entry = entries(idx);
+ end
+ end
+ end
+end
diff --git a/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m b/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m
index 6a17646a3..01aff9992 100644
--- a/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m
+++ b/code/+nansen/+plugin/+fileadapter/+internal/detectFileAdapterForFilepath.m
@@ -19,15 +19,8 @@
if isempty(fileAdapterList)
error('No file adapters exist that can open files of type "%s"', fileExtension)
- elseif numel(fileAdapterList) > 1
- fileAdapterNames = {fileAdapterList.FunctionName};
- fileAdapterNames = nansen.util.text.strArrayToBulletList(fileAdapterNames);
-
- % warning(...
- % ['Multiple matching file adapters: \n%s\n Using first one. ', ...
- % 'To use another file adapter, please specify the file ', ...
- % 'adapter using the "FileAdapter" input'], fileAdapterNames)
end
+
fileAdapterName = fileAdapterList(1).FunctionName;
isDynamic = fileAdapterList(1).IsDynamic;
end
diff --git a/code/+nansen/+plugin/+fileadapter/Registry.m b/code/+nansen/+plugin/+fileadapter/Registry.m
new file mode 100644
index 000000000..a06b8f7a6
--- /dev/null
+++ b/code/+nansen/+plugin/+fileadapter/Registry.m
@@ -0,0 +1,98 @@
+classdef Registry < nansen.plugin.base.Registry
+%Registry Project-owned registry for FileAdapter plugins.
+%
+% Scans +fileadapter/ subfolders of registered sources and provides the
+% same struct-array shape that nansen.dataio.listFileAdapters previously
+% returned via Module.getTable.
+%
+% Fields per entry:
+% FileAdapterName - Class name (string)
+% FunctionName - Full MATLAB function/package name (string)
+% SupportedFileTypes - Supported extensions (string array)
+% DataType - Data type returned on load (string)
+% IsDynamic - true for sidecar-based, false for class-based
+%
+% See also: nansen.plugin.base.Registry, nansen.dataio.listFileAdapters
+
+ methods % Public list extension
+ function entries = list(obj, fileExtension)
+ %list Return the struct array, optionally filtered by file extension.
+ %
+ % entries = list() returns all adapters.
+ % entries = list(fileExtension) filters to adapters that support the
+ % given extension (with or without a leading dot).
+ arguments
+ obj
+ fileExtension (1,1) string = ""
+ end
+ entries = list@nansen.plugin.base.Registry(obj);
+
+ if fileExtension == "" || isempty(entries)
+ return
+ end
+
+ ext = strrep(fileExtension, ".", "");
+ matchFcn = @(extList) any(contains(extList, ext, "IgnoreCase", true));
+ keep = arrayfun(@(s) matchFcn(s.SupportedFileTypes), entries);
+ entries = entries(keep);
+ end
+ end
+
+ methods (Access = protected)
+ function id = primaryId(~, entry)
+ %primaryId Return the FileAdapterName as the unique entry id.
+ id = string(entry.FileAdapterName);
+ end
+
+ function entries = scanSource(obj, sourceId, sourcePath) %#ok
+ %scanSource Scan a +fileadapter/ subfolder for adapters.
+ %
+ % Returns a struct array with the five standard adapter fields.
+ % Returns an empty struct with those fields if the source has no
+ % +fileadapter/ subfolder.
+ emptyEntry = struct( ...
+ 'FileAdapterName', {}, ...
+ 'FunctionName', {}, ...
+ 'SupportedFileTypes', {}, ...
+ 'DataType', {}, ...
+ 'IsDynamic', {});
+
+ fileadapterRoot = fullfile(sourcePath, '+fileadapter');
+ if ~isfolder(fileadapterRoot)
+ entries = emptyEntry;
+ return
+ end
+
+ mFiles = nansen.module.Module.listMFiles( ...
+ fileadapterRoot, ["read.m", "write.m", "view.m"]);
+ jsonFiles = nansen.module.Module.listJsonFiles( ...
+ fileadapterRoot, 'fileadapter');
+ fileList = [mFiles; jsonFiles];
+
+ if isempty(fileList)
+ entries = emptyEntry;
+ return
+ end
+
+ tbl = nansen.dataio.FileAdapter.buildFileAdapterTable(fileList);
+ if isempty(tbl)
+ entries = emptyEntry;
+ else
+ entries = table2struct(tbl);
+ entries = obj.normalizeEntries(entries);
+ end
+ end
+ end
+
+ methods (Access = private)
+ function entries = normalizeEntries(~, entries)
+ %normalizeEntries Keep file adapter metadata text fields as strings.
+ for i = 1:numel(entries)
+ entries(i).FileAdapterName = string(entries(i).FileAdapterName);
+ entries(i).FunctionName = string(entries(i).FunctionName);
+ entries(i).SupportedFileTypes = string(entries(i).SupportedFileTypes);
+ entries(i).DataType = string(entries(i).DataType);
+ end
+ end
+ end
+end
diff --git a/code/+nansen/+plugin/editFileAdapter.m b/code/+nansen/+plugin/editFileAdapter.m
index 417b51de7..d0a8eda51 100644
--- a/code/+nansen/+plugin/editFileAdapter.m
+++ b/code/+nansen/+plugin/editFileAdapter.m
@@ -7,9 +7,9 @@ function editFileAdapter(fileAdapterName)
fileAdapterList = nansen.dataio.listFileAdapters();
if contains(fileAdapterName, 'fileadapter.')
- isMatch = strcmp(fileAdapterName, {fileAdapterList.FunctionName});
+ isMatch = [fileAdapterList.FunctionName] == fileAdapterName;
elseif ~contains(fileAdapterName, '.')
- isMatch = strcmp(fileAdapterName, {fileAdapterList.FileAdapterName});
+ isMatch = [fileAdapterList.FileAdapterName] == fileAdapterName;
else
error('"%s" is not the name of a file adapter', fileAdapterName)
end
diff --git a/code/+nansen/load.m b/code/+nansen/load.m
index 8291c7865..b73984eda 100644
--- a/code/+nansen/load.m
+++ b/code/+nansen/load.m
@@ -14,9 +14,9 @@
else
fileAdapterName = options.FileAdapter;
if contains(fileAdapterName, 'fileadapter.')
- isMatch = strcmp(fileAdapterName, {fileAdapterList.FunctionName});
+ isMatch = [fileAdapterList.FunctionName] == fileAdapterName;
elseif ~contains(fileAdapterName, '.')
- isMatch = strcmp(fileAdapterName, {fileAdapterList.FileAdapterName});
+ isMatch = [fileAdapterList.FileAdapterName] == fileAdapterName;
fileAdapterName = fileAdapterList(isMatch).FunctionName;
else
error('"%s" is not the name of a file adapter', fileAdapterName)
diff --git a/code/+nansen/save.m b/code/+nansen/save.m
index 8ce543bcb..fc429e56f 100644
--- a/code/+nansen/save.m
+++ b/code/+nansen/save.m
@@ -15,9 +15,9 @@ function save(filePath, data, options)
else
fileAdapterName = options.FileAdapter;
if contains(fileAdapterName, 'fileadapter.')
- isMatch = strcmp(fileAdapterName, {fileAdapterList.FunctionName});
+ isMatch = [fileAdapterList.FunctionName] == fileAdapterName;
elseif ~contains(fileAdapterName, '.')
- isMatch = strcmp(fileAdapterName, {fileAdapterList.FileAdapterName});
+ isMatch = [fileAdapterList.FileAdapterName] == fileAdapterName;
fileAdapterName = fileAdapterList(isMatch).FunctionName;
else
error('"%s" is not the name of a file adapter', fileAdapterName)
diff --git a/code/apps/+nansen/@FileViewer/FileViewer.m b/code/apps/+nansen/@FileViewer/FileViewer.m
index 353620ae2..e9a24dab9 100644
--- a/code/apps/+nansen/@FileViewer/FileViewer.m
+++ b/code/apps/+nansen/@FileViewer/FileViewer.m
@@ -1286,7 +1286,7 @@ function onViewFileAdapterItemClicked(obj, src, filePath)
fileAdapterName = src.Text;
fileAdapterList = nansen.dataio.listFileAdapters();
- isMatch = strcmp({fileAdapterList.FileAdapterName}, fileAdapterName);
+ isMatch = [fileAdapterList.FileAdapterName] == string(fileAdapterName);
edit( sprintf('%s/read', fileAdapterList(isMatch).FunctionName) );
end
diff --git a/tests/+nansen/+unittest/+plugin/+helper/ConcreteRegistry.m b/tests/+nansen/+unittest/+plugin/+helper/ConcreteRegistry.m
new file mode 100644
index 000000000..34b1d02fa
--- /dev/null
+++ b/tests/+nansen/+unittest/+plugin/+helper/ConcreteRegistry.m
@@ -0,0 +1,23 @@
+classdef ConcreteRegistry < nansen.plugin.base.Registry
+%ConcreteRegistry Test double for nansen.plugin.base.Registry.
+%
+% scanSource reads a plugins.mat file from the given directory. Each
+% element in the 'plugins' variable must be a struct with at least a
+% 'PluginId' field. The PluginId is used as the primary identifier.
+
+ methods (Access = protected)
+ function id = primaryId(~, entry)
+ id = string(entry.PluginId);
+ end
+
+ function entries = scanSource(~, ~, sourcePath)
+ matFile = fullfile(sourcePath, 'plugins.mat');
+ if ~isfile(matFile)
+ entries = struct('PluginId', {}, 'Source', {});
+ return
+ end
+ loaded = load(matFile, 'plugins');
+ entries = loaded.plugins;
+ end
+ end
+end
diff --git a/tests/+nansen/+unittest/+plugin/FileAdapterRegistryTest.m b/tests/+nansen/+unittest/+plugin/FileAdapterRegistryTest.m
new file mode 100644
index 000000000..d141b79ae
--- /dev/null
+++ b/tests/+nansen/+unittest/+plugin/FileAdapterRegistryTest.m
@@ -0,0 +1,171 @@
+classdef FileAdapterRegistryTest < matlab.unittest.TestCase
+%FileAdapterRegistryTest Tests for nansen.plugin.fileadapter.Registry.
+%
+% Uses temporary directories so no Project object is needed. Verifies
+% that the registry is project-agnostic and that class-based and
+% sidecar-based adapters are discovered correctly.
+
+ properties
+ TempDir (1,1) string
+ end
+
+ methods (TestMethodSetup)
+ function createTempDir(testCase)
+ testCase.TempDir = string(tempname());
+ mkdir(testCase.TempDir)
+ testCase.addTeardown(@() rmdir(testCase.TempDir, 's'))
+ end
+ end
+
+ methods (Test)
+
+ function testSourceWithNoFileadapterFolderIsEmpty(testCase)
+ reg = nansen.plugin.fileadapter.Registry();
+ emptyDir = fullfile(testCase.TempDir, 'empty_source');
+ mkdir(emptyDir)
+
+ reg.pinBottom("empty", emptyDir);
+ reg.reconcile();
+
+ testCase.verifyEmpty(reg.list())
+ end
+
+ function testRegistryIsProjectAgnostic(testCase)
+ % The registry can be constructed and used without any Project.
+ reg = nansen.plugin.fileadapter.Registry();
+ dirA = testCase.makeSidecarSource('sourceA', 'TestSidecar', 'mat');
+ reg.pinBottom("src-a", dirA);
+ reg.reconcile();
+
+ entries = reg.list();
+ testCase.verifyNotEmpty(entries)
+ testCase.verifyTrue(any([entries.FileAdapterName] == "TestSidecar"))
+ end
+
+ function testSidecarAdapterDiscovery(testCase)
+ reg = nansen.plugin.fileadapter.Registry();
+ srcDir = testCase.makeSidecarSource('src', 'MyAdapter', 'csv');
+ reg.pinBottom("src", srcDir);
+ reg.reconcile();
+
+ entries = reg.list();
+ testCase.verifyNumElements(entries, 1)
+ testCase.verifyEqual(entries.FileAdapterName, "MyAdapter")
+ testCase.verifyTrue(entries.IsDynamic)
+ end
+
+ function testFilterByFileExtension(testCase)
+ reg = nansen.plugin.fileadapter.Registry();
+ srcDir = fullfile(testCase.TempDir, 'multi');
+ testCase.makeSidecarSourceInDir(srcDir, 'TxtAdapter', 'txt');
+ testCase.makeSidecarSourceInDir(srcDir, 'CsvAdapter', 'csv');
+ reg.pinBottom("src", srcDir);
+ reg.reconcile();
+
+ csvEntries = reg.list("csv");
+ testCase.verifyNumElements(csvEntries, 1)
+ testCase.verifyEqual(csvEntries.FileAdapterName, "CsvAdapter")
+ end
+
+ function testFilterByExtensionWithDot(testCase)
+ reg = nansen.plugin.fileadapter.Registry();
+ srcDir = testCase.makeSidecarSource('src', 'MatAdapter', 'mat');
+ reg.pinBottom("src", srcDir);
+ reg.reconcile();
+
+ withDot = reg.list(".mat");
+ withoutDot = reg.list("mat");
+ testCase.verifyEqual([withDot.FileAdapterName], [withoutDot.FileAdapterName])
+ end
+
+ function testSourceAddAndRemoveDynamic(testCase)
+ reg = nansen.plugin.fileadapter.Registry();
+ srcA = testCase.makeSidecarSource('A', 'AdapterA', 'h5');
+ srcB = testCase.makeSidecarSource('B', 'AdapterB', 'npy');
+
+ reg.addSource("src-a", srcA);
+ reg.reconcile();
+ testCase.verifyNumElements(reg.list(), 1)
+
+ reg.addSource("src-b", srcB);
+ reg.reconcile();
+ testCase.verifyNumElements(reg.list(), 2)
+
+ reg.removeSource("src-a");
+ reg.reconcile();
+ entries = reg.list();
+ testCase.verifyNumElements(entries, 1)
+ testCase.verifyEqual(entries.FileAdapterName, "AdapterB")
+ end
+
+ function testTopSourceWinsOnConflict(testCase)
+ reg = nansen.plugin.fileadapter.Registry();
+ srcBottom = testCase.makeSidecarSource('Bottom', 'SharedName', 'tif');
+ srcTop = testCase.makeSidecarSource('Top', 'SharedName', 'tiff');
+
+ reg.pinBottom("bottom", srcBottom);
+ reg.pinTop("top", srcTop);
+ reg.reconcile();
+
+ entries = reg.list();
+ testCase.verifyNumElements(entries, 1)
+ % Top source should win; its supported file type is '.tiff'
+ testCase.verifyEqual(entries.SupportedFileTypes, ".tiff")
+ end
+
+ function testModuleDeprecationWarning(testCase)
+ module = nansen.module.Module.fromName( ...
+ nansen.common.constant.BaseModuleName);
+ testCase.verifyWarning( ...
+ @() module.FileAdapters, ...
+ 'NANSEN:DeprecatedPath:ModuleFileAdapters')
+ end
+
+ function testGetTableFileAdapterDeprecationWarning(testCase)
+ module = nansen.module.Module.fromName( ...
+ nansen.common.constant.BaseModuleName);
+ testCase.verifyWarning( ...
+ @() module.getTable('FileAdapter'), ...
+ 'NANSEN:DeprecatedPath:ModuleFileAdapters')
+ end
+ end
+
+ methods (Access = private)
+ function srcDir = makeSidecarSource(testCase, name, adapterName, ext)
+ %makeSidecarSource Create a temp source dir with one sidecar adapter.
+ srcDir = fullfile(testCase.TempDir, name);
+ testCase.makeSidecarSourceInDir(srcDir, adapterName, ext);
+ end
+
+ function makeSidecarSourceInDir(~, srcDir, adapterName, ext)
+ %makeSidecarSourceInDir Write a fileadapter.json sidecar into srcDir.
+ %
+ % Writes the JSON with a "_type" field (dot-prefixed extension),
+ % matching the format the parser expects.
+ faDir = fullfile(srcDir, '+fileadapter', ['+', adapterName]);
+ mkdir(faDir)
+
+ % Build JSON manually so the field is literally "_type"
+ % (jsonencode would produce "x_type" from a struct, which also
+ % works after jsondecode, but this matches the canonical format).
+ jsonStr = sprintf( ...
+ ['{\n', ...
+ ' "_type": "FileAdapter",\n', ...
+ ' "_version": "1.0.0",\n', ...
+ ' "Properties": {\n', ...
+ ' "Name": "%s",\n', ...
+ ' "Description": "",\n', ...
+ ' "SupportedFileTypes": [".%s"],\n', ...
+ ' "FileExpression": "",\n', ...
+ ' "DataType": "generic",\n', ...
+ ' "IsGeneral": false,\n', ...
+ ' "ReadFunction": "",\n', ...
+ ' "WriteFunction": "",\n', ...
+ ' "ViewFunction": ""\n', ...
+ ' }\n', ...
+ '}'], adapterName, ext);
+
+ utility.filewrite(fullfile(faDir, 'fileadapter.json'), jsonStr)
+ end
+ end
+end
diff --git a/tests/+nansen/+unittest/+plugin/RegistryBaseTest.m b/tests/+nansen/+unittest/+plugin/RegistryBaseTest.m
new file mode 100644
index 000000000..1c78e8777
--- /dev/null
+++ b/tests/+nansen/+unittest/+plugin/RegistryBaseTest.m
@@ -0,0 +1,236 @@
+classdef RegistryBaseTest < matlab.unittest.TestCase
+%RegistryBaseTest Unit tests for nansen.plugin.base.Registry.
+%
+% Tests source pinning, middle-tier ordering, removeSource from any slot,
+% reconcile event emission, and precedence-based dedup.
+
+ properties
+ TempDir (1,1) string % Temporary directory root for test sources
+ end
+
+ methods (TestMethodSetup)
+ function createTempDir(testCase)
+ testCase.TempDir = string(tempname());
+ mkdir(testCase.TempDir)
+ testCase.addTeardown(@() rmdir(testCase.TempDir, 's'))
+ end
+ end
+
+ methods (Test)
+
+ function testEmptyRegistryListReturnsEmpty(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ entries = reg.list();
+ testCase.verifyEmpty(entries)
+ end
+
+ function testPinTopSingleSlot(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+ dirB = testCase.makeSourceDir('B', {'Beta'});
+
+ reg.pinTop("src-a", dirA);
+ reg.reconcile();
+ testCase.verifyEqual([reg.list().PluginId], "Alpha")
+
+ % Replacing the top slot should switch to the new source
+ reg.pinTop("src-b", dirB);
+ reg.reconcile();
+ testCase.verifyEqual([reg.list().PluginId], "Beta")
+ end
+
+ function testPinBottomSingleSlot(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+ dirB = testCase.makeSourceDir('B', {'Beta'});
+
+ reg.pinBottom("src-a", dirA);
+ reg.reconcile();
+ testCase.verifyEqual([reg.list().PluginId], "Alpha")
+
+ reg.pinBottom("src-b", dirB);
+ reg.reconcile();
+ testCase.verifyEqual([reg.list().PluginId], "Beta")
+ end
+
+ function testMiddleTierOrderedAppend(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+ dirB = testCase.makeSourceDir('B', {'Beta'});
+ dirC = testCase.makeSourceDir('C', {'Gamma'});
+
+ reg.addSource("src-a", dirA);
+ reg.addSource("src-b", dirB);
+ reg.addSource("src-c", dirC);
+ reg.reconcile();
+
+ ids = [reg.list().PluginId];
+ testCase.verifyEqual(ids, ["Alpha", "Beta", "Gamma"])
+ end
+
+ function testAddSourceErrorsOnDuplicateId(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dir1 = testCase.makeSourceDir('X1', {'P1'});
+ dir2 = testCase.makeSourceDir('X2', {'P2'});
+ reg.addSource("dup-id", dir1);
+ testCase.verifyError(@() reg.addSource("dup-id", dir2), ...
+ 'NANSEN:Registry:DuplicateSourceId')
+ end
+
+ function testRemoveSourceFromMiddle(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+ dirB = testCase.makeSourceDir('B', {'Beta'});
+ reg.addSource("src-a", dirA);
+ reg.addSource("src-b", dirB);
+ reg.reconcile();
+
+ reg.removeSource("src-a");
+ reg.reconcile();
+ ids = [reg.list().PluginId];
+ testCase.verifyEqual(ids, "Beta")
+ end
+
+ function testRemoveSourceFromTop(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+ reg.pinTop("top", dirA);
+ reg.reconcile();
+ testCase.verifyNumElements(reg.list(), 1)
+
+ reg.removeSource("top");
+ reg.reconcile();
+ testCase.verifyEmpty(reg.list())
+ end
+
+ function testRemoveSourceFromBottom(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirB = testCase.makeSourceDir('B', {'Beta'});
+ reg.pinBottom("bottom", dirB);
+ reg.reconcile();
+ testCase.verifyNumElements(reg.list(), 1)
+
+ reg.removeSource("bottom");
+ reg.reconcile();
+ testCase.verifyEmpty(reg.list())
+ end
+
+ function testRemoveUnknownSourceIsNoop(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+ reg.addSource("src-a", dirA);
+ reg.reconcile();
+
+ % Should not error
+ reg.removeSource("nonexistent");
+ reg.reconcile();
+ testCase.verifyNumElements(reg.list(), 1)
+ end
+
+ function testPrecedenceTopWinsOverMiddleAndBottom(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+
+ % Same plugin id 'Alpha' in all three tiers
+ dirTop = testCase.makeSourceDir('Top', {'Alpha'}, struct('Source', 'top'));
+ dirMiddle = testCase.makeSourceDir('Middle', {'Alpha'}, struct('Source', 'middle'));
+ dirBottom = testCase.makeSourceDir('Bottom', {'Alpha'}, struct('Source', 'bottom'));
+
+ reg.pinBottom("bottom", dirBottom);
+ reg.addSource("middle", dirMiddle);
+ reg.pinTop("top", dirTop);
+ reg.reconcile();
+
+ entries = reg.list();
+ testCase.verifyNumElements(entries, 1)
+ testCase.verifyEqual(entries.Source, "top")
+ end
+
+ function testReconcileEmitsPluginAddedOnNew(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+
+ addedCount = 0;
+ addlistener(reg, 'PluginAdded', @(~,~) incr());
+
+ reg.addSource("src-a", dirA);
+ reg.reconcile();
+ testCase.verifyEqual(addedCount, 1)
+
+ function incr(); addedCount = addedCount + 1; end
+ end
+
+ function testReconcileEmitsPluginRemovedOnMissing(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha'});
+
+ removedCount = 0;
+ addlistener(reg, 'PluginRemoved', @(~,~) incr());
+
+ reg.addSource("src-a", dirA);
+ reg.reconcile();
+
+ reg.removeSource("src-a");
+ reg.reconcile();
+ testCase.verifyEqual(removedCount, 1)
+
+ function incr(); removedCount = removedCount + 1; end
+ end
+
+ function testReconcileEmitsPluginRegistryReconciled(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ reconciledCount = 0;
+ addlistener(reg, 'PluginRegistryReconciled', @(~,~) incr());
+
+ reg.reconcile();
+ reg.reconcile();
+ testCase.verifyEqual(reconciledCount, 2)
+
+ function incr(); reconciledCount = reconciledCount + 1; end
+ end
+
+ function testGetReturnsCorrectEntry(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ dirA = testCase.makeSourceDir('A', {'Alpha', 'Beta'});
+ reg.addSource("src-a", dirA);
+ reg.reconcile();
+
+ alpha = reg.get("Alpha");
+ testCase.verifyEqual(alpha.PluginId, "Alpha")
+ end
+
+ function testGetReturnsEmptyForUnknownId(testCase)
+ reg = nansen.unittest.plugin.helper.ConcreteRegistry();
+ entry = reg.get("DoesNotExist");
+ testCase.verifyEmpty(entry)
+ end
+ end
+
+ methods (Access = private)
+ function dirPath = makeSourceDir(testCase, name, pluginIds, extraFields)
+ %makeSourceDir Create a temp directory that scanSource will read.
+ %
+ % The ConcreteRegistry's scanSource reads a plugins.mat file written
+ % by this helper.
+ if nargin < 4
+ extraFields = struct();
+ end
+ dirPath = fullfile(testCase.TempDir, name);
+ mkdir(dirPath)
+
+ plugins = struct('PluginId', {}, 'Source', {});
+ for i = 1:numel(pluginIds)
+ s = struct('PluginId', string(pluginIds{i}), 'Source', string(name));
+ fields = fieldnames(extraFields);
+ for f = 1:numel(fields)
+ fieldValue = extraFields.(fields{f});
+ if ischar(fieldValue) || isstring(fieldValue)
+ fieldValue = string(fieldValue);
+ end
+ s.(fields{f}) = fieldValue;
+ end
+ plugins(end+1) = s; %#ok
+ end
+ save(fullfile(dirPath, 'plugins.mat'), 'plugins')
+ end
+ end
+end