diff --git a/code/+openminds/@Collection/Collection.m b/code/+openminds/@Collection/Collection.m index b7cf54af2..c53fc20b3 100644 --- a/code/+openminds/@Collection/Collection.m +++ b/code/+openminds/@Collection/Collection.m @@ -522,12 +522,24 @@ function load(obj, loadPath, options) || startsWith(instance.id, "https://openminds.om-i.org/instances/"); if isControlledInstance - % A controlled instance is a reference into the instance - % library, which every reader has, so it can be a node of - % the collection. Whether it is one is a preference. + % A controlled instance is in the instance library, which + % every reader has, so it can be a node of the collection. + % Whether it is one is a preference. if ~openminds.getpref('AddControlledInstanceToCollection') return end + + if instance.isReference() + % A reference, typed or not, is replaced by the instance + % from the library, so that the node has the properties + % of the controlled instance and not only its + % identifier. A reference to an instance the library + % does not have stays a link. + instance = getLibraryInstance(instance.id); + if isempty(instance) + return + end + end elseif instance.isReference() % Any other reference stands for a node that is not here, % whether its type is known or not. It is not a node @@ -679,3 +691,27 @@ function refreshTypeKeys(obj, instanceType) end end end + +function instance = getLibraryInstance(identifier) +%getLibraryInstance - The controlled instance with an identifier, taken from the instance library +% Returns [] when the library does not have the instance. The lookup +% can fail in several ways, for example when the name is not in the +% library or the type has no library instances, and all of them mean +% that the link stays a link. A controlled term whose name the library +% does not have is created with the warning +% openMINDS:ControlledTerm:UnknownInstanceName rather than an error, +% because a user may define a term of their own, so that warning is +% raised as an error here. + + unknownNameId = 'openMINDS:ControlledTerm:UnknownInstanceName'; + warningState = warning('error', unknownNameId); + restoreWarning = onCleanup(@() warning(warningState)); + + try + instance = openminds.instanceFromIRI(identifier); + catch + % The instance is not in the library; the caller keeps the link + instance = []; + end + clear restoreWarning +end diff --git a/tools/tests/unitTests/CollectionTest.m b/tools/tests/unitTests/CollectionTest.m index 960eebb88..4135c842e 100644 --- a/tools/tests/unitTests/CollectionTest.m +++ b/tools/tests/unitTests/CollectionTest.m @@ -1,6 +1,10 @@ classdef CollectionTest < matlab.unittest.TestCase % CollectionTest - Unit tests for the openminds metadata collection class - + + properties (Constant, Access = private) + HomoSapiensIRI = "https://openminds.om-i.org/instances/species/homoSapiens" + end + methods (TestMethodSetup) function createTempDir(testCase) import matlab.unittest.fixtures.WorkingFolderFixture @@ -528,6 +532,101 @@ function testUnresolvedLinkIsNotANode(testCase) testCase.verifyFalse(contains(document, "MixedTypeReference")); end + function testUnresolvedControlledInstanceLinkIsNotANode(testCase) + % A link to a controlled instance that could not be taken from + % the instance library stays a reference. With controlled + % instances added to collections, it is still not a node, so + % saving writes it as a reference instead of failing on it. + original = openminds.getpref('AddControlledInstanceToCollection'); + testCase.addTeardown(@() openminds.setpref( ... + 'AddControlledInstanceToCollection', original)); + openminds.setpref('AddControlledInstanceToCollection', true); + + referenceIRI = "https://openminds.om-i.org/instances/species/notInTheLibrary"; + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.internal.MixedTypeReference(referenceIRI); + + collection = openminds.Collection(subject); + testCase.verifyEqual(length(collection), 1); + + filePath = "unresolved-controlled-instance-collection.jsonld"; + collection.save(filePath); + + document = fileread(filePath); + testCase.verifyTrue(contains(document, referenceIRI)); + testCase.verifyFalse(contains(document, "MixedTypeReference")); + end + + function testTypedControlledReferenceBecomesLibraryInstance(testCase) + % With controlled instances added to collections, a typed + % reference to one is replaced by the instance from the + % library, so the saved node has the instance's properties. + testCase.setControlledInstancePreference(true) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", testCase.HomoSapiensIRI, "IsReference", true); + + document = testCase.saveCollection(openminds.Collection(subject), "typed-reference.jsonld"); + + testCase.verifyTrue(contains(document, """name"": ""Homo sapiens""")); + end + + function testUntypedControlledReferenceBecomesLibraryInstance(testCase) + testCase.setControlledInstancePreference(true) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.internal.MixedTypeReference(testCase.HomoSapiensIRI); + + collection = openminds.Collection(subject); + document = testCase.saveCollection(collection, "untyped-reference.jsonld"); + + testCase.verifyEqual(length(collection), 2); + testCase.verifyTrue(contains(document, """name"": ""Homo sapiens""")); + end + + function testTypedControlledReferenceNotInLibraryStaysLink(testCase) + testCase.setControlledInstancePreference(true) + referenceIRI = "https://openminds.om-i.org/instances/species/notInTheLibrary"; + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", referenceIRI, "IsReference", true); + + collection = openminds.Collection(subject); + document = testCase.saveCollection(collection, "typed-reference-not-in-library.jsonld"); + + testCase.verifyEqual(length(collection), 1); + testCase.verifyTrue(contains(document, referenceIRI)); + end + + function testControlledReferenceStaysLinkWhenPreferenceIsOff(testCase) + testCase.setControlledInstancePreference(false) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", testCase.HomoSapiensIRI, "IsReference", true); + + collection = openminds.Collection(subject); + document = testCase.saveCollection(collection, "reference-preference-off.jsonld"); + + testCase.verifyEqual(length(collection), 1); + testCase.verifyTrue(contains(document, testCase.HomoSapiensIRI)); + testCase.verifyFalse(contains(document, "Homo sapiens")); + end + + function testControlledReferenceRoundTripIsStable(testCase) + % Saving writes the library instance; loading that file and + % saving again writes the same document. + testCase.setControlledInstancePreference(true) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", testCase.HomoSapiensIRI, "IsReference", true); + + firstDocument = testCase.saveCollection(openminds.Collection(subject), "first.jsonld"); + reloaded = openminds.Collection("first.jsonld"); + secondDocument = testCase.saveCollection(reloaded, "second.jsonld"); + + testCase.verifyEqual(sort(splitlines(string(secondDocument))), ... + sort(splitlines(string(firstDocument)))); + end + function testTypedReferenceSurvivesRoundTrip(testCase) % A reference whose type is known is still a reference, not a % node with no properties. It gets no file of its own, so @@ -695,7 +794,22 @@ function testSaveInstances(testCase) % % testCase.verifyTrue(startsWith(char(identifier), '_:')); % % end end - + + methods (Access = private) + function setControlledInstancePreference(testCase, value) + % Sets AddControlledInstanceToCollection for one test + original = openminds.getpref('AddControlledInstanceToCollection'); + testCase.addTeardown(@() openminds.setpref( ... + 'AddControlledInstanceToCollection', original)); + openminds.setpref('AddControlledInstanceToCollection', value); + end + + function document = saveCollection(~, collection, filePath) + collection.save(filePath); + document = fileread(filePath); + end + end + methods (Static, Access = private) function [dataset, affiliation] = datasetWithOneContributorAffiliation() [person, affiliation] = personWithOneAffiliation();