From ecd6cfe72866752ff0ed63989413e41437ac56d3 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 08:17:25 +0200 Subject: [PATCH 1/3] test: read the instance library from a small library kept with the tests The instance library's tests read the library the toolbox downloads, so they depended on a network, on seventy megabytes arriving intact, and on whatever upstream held on the day. Their assumptions turned a missing or incomplete download into a silent skip, and cases that needed a library in a known state, a damaged document, an empty version folder, a folder that is not a library, could only be checked by hand. A small library now lives with the tests: six real instance documents chosen to cover the layouts the class has to read, one type per folder under terminologies, the plural folder names of licenses and content types, and an atlas subfolder of parcellation entities. Every type in it exists in every model version from v3.0 on. ommtest.helper.InstanceLibraryFixture copies that library into a temporary folder and makes it the library in use, so a test reads known instances and may change its copy. Afterwards the library in use is deleted, not read again from the default location, so a suite that never needs the downloaded library never downloads it. InstanceLibraryTest reads the small library, with precise expectations and the cases that were checked by hand now checked here. What only the downloaded library can answer, that every instance upstream ships resolves to a type and that the library follows the model version across the versions upstream publishes, moves to InstanceLibraryIntegrationTest and keeps its assumption. Co-Authored-By: Claude Fable 5.1 --- .../+ommtest/+helper/InstanceLibraryFixture.m | 87 ++++++ .../latest/contentTypes/text_plain.jsonld | 20 ++ .../instances/latest/licenses/MIT.jsonld | 14 + .../BA-human/BA-human_BA32.jsonld | 24 ++ .../terminologies/ageCategory/adult.jsonld | 21 ++ .../ageCategory/youngAdult.jsonld | 19 ++ .../terminologies/biologicalSex/male.jsonld | 17 ++ .../InstanceLibraryIntegrationTest.m | 132 +++++++++ tools/tests/unitTests/InstanceLibraryTest.m | 264 +++++++----------- 9 files changed, 442 insertions(+), 156 deletions(-) create mode 100644 tools/tests/+ommtest/+helper/InstanceLibraryFixture.m create mode 100644 tools/tests/fixtures/instances/latest/contentTypes/text_plain.jsonld create mode 100644 tools/tests/fixtures/instances/latest/licenses/MIT.jsonld create mode 100644 tools/tests/fixtures/instances/latest/parcellationEntities/BA-human/BA-human_BA32.jsonld create mode 100644 tools/tests/fixtures/instances/latest/terminologies/ageCategory/adult.jsonld create mode 100644 tools/tests/fixtures/instances/latest/terminologies/ageCategory/youngAdult.jsonld create mode 100644 tools/tests/fixtures/instances/latest/terminologies/biologicalSex/male.jsonld create mode 100644 tools/tests/unitTests/InstanceLibraryIntegrationTest.m diff --git a/tools/tests/+ommtest/+helper/InstanceLibraryFixture.m b/tools/tests/+ommtest/+helper/InstanceLibraryFixture.m new file mode 100644 index 000000000..7601a5acf --- /dev/null +++ b/tools/tests/+ommtest/+helper/InstanceLibraryFixture.m @@ -0,0 +1,87 @@ +classdef InstanceLibraryFixture < matlab.unittest.fixtures.Fixture +%InstanceLibraryFixture Read a small instance library for the duration of a test +% +% The instance library is one object per session, read by default from +% the library downloaded under the user folder. A test that reads it +% there depends on a network, on a download of seventy megabytes, and +% on whatever upstream holds on the day. This fixture copies a small +% library kept with the tests into a temporary folder and makes that the +% library in use, so a test reads a known set of instances, and may +% change the copy as it likes. +% +% Usage: +% fixture = testCase.applyFixture(ommtest.helper.InstanceLibraryFixture); +% fixture.Library % the instance library reading the copy +% fixture.Folder % the copy, one subfolder per library version +% +% Use fixture.Library rather than getSingleton with no argument: the +% library asked for by no argument is the one at the default location, +% and asking for it replaces the fixture's library with the downloaded +% one. Ask for getSingleton(fixture.Folder) to get the fixture's library +% back after it has been rebuilt. +% +% The library kept with the tests is under tools/tests/fixtures/instances +% and holds, for the "latest" version: two age categories and a +% biological sex, stored one type per folder under terminologies; a +% license and a content type, stored under the plural folder names +% their IRIs also use; and a parcellation entity, stored in a subfolder +% of its atlas. Every type exists in every model version from v3.0 on. +% +% Afterwards the library in use is deleted, and the next use reads the +% default location again. It is deleted rather than read again here, so +% that a suite which never needs the downloaded library never downloads +% it. A handle to the library obtained before the fixture is applied is +% deleted when it is applied, as any change of location deletes the +% library in use. +% +% Each application makes its own copy, so nothing a test changes is seen +% by another. +% +% See also matlab.unittest.fixtures.Fixture, openminds.internal.InstanceLibrary + + properties (SetAccess = private) + % Folder - The temporary copy of the library this fixture reads + Folder (1,1) string = missing + + % Library - The instance library reading that copy + Library + end + + methods + function setup(fixture) + fixture.Folder = string(tempname()); + copyfile(sourceFolder(), fixture.Folder); + fixture.addTeardown(@() rmdir(fixture.Folder, "s")); + + % Registered before the library is replaced, so the restore is + % on record from the moment there is something to restore, and + % it runs before the copy is removed, since teardowns run in + % the reverse of the order they were added. + fixture.Library = ... + openminds.internal.InstanceLibrary.getSingleton(fixture.Folder); + fixture.addTeardown(@() delete(fixture.Library)); + + fixture.SetupDescription = sprintf( ... + 'Read the instance library from a copy of the test library at "%s".', ... + fixture.Folder); + fixture.TeardownDescription = ... + 'Deleted that library, so the next use reads the default location.'; + end + end + + methods (Access = protected) + function tf = isCompatible(~, ~) + % isCompatible - Whether a shared fixture can stand in for another + % + % Never: each application makes its own copy, so that what one + % test changes is not what the next one reads. + tf = false; + end + end +end + +function folderPath = sourceFolder() +% sourceFolder - The library kept with the tests + testsFolder = fileparts(fileparts(fileparts( mfilename('fullpath') ))); + folderPath = fullfile(testsFolder, "fixtures", "instances"); +end diff --git a/tools/tests/fixtures/instances/latest/contentTypes/text_plain.jsonld b/tools/tests/fixtures/instances/latest/contentTypes/text_plain.jsonld new file mode 100644 index 000000000..e215808a7 --- /dev/null +++ b/tools/tests/fixtures/instances/latest/contentTypes/text_plain.jsonld @@ -0,0 +1,20 @@ +{ + "@context": { + "@vocab": "https://openminds.om-i.org/props/" + }, + "@id": "https://openminds.om-i.org/instances/contentTypes/text_plain", + "@type": "https://openminds.om-i.org/types/ContentType", + "dataType": null, + "definingSource": null, + "description": null, + "displayLabel": null, + "fileExtension": [ + ".txt" + ], + "isBasedOn": null, + "name": "text/plain", + "specification": null, + "synonym": [ + "plain text file format" + ] +} diff --git a/tools/tests/fixtures/instances/latest/licenses/MIT.jsonld b/tools/tests/fixtures/instances/latest/licenses/MIT.jsonld new file mode 100644 index 000000000..96f79a47b --- /dev/null +++ b/tools/tests/fixtures/instances/latest/licenses/MIT.jsonld @@ -0,0 +1,14 @@ +{ + "@context": { + "@vocab": "https://openminds.om-i.org/props/" + }, + "@id": "https://openminds.om-i.org/instances/licenses/MIT", + "@type": "https://openminds.om-i.org/types/License", + "fullName": "MIT License", + "legalCode": "https://spdx.org/licenses/MIT.html", + "shortName": "MIT", + "webpage": [ + "https://opensource.org/licenses/MIT", + "https://spdx.org/licenses/MIT.html" + ] +} diff --git a/tools/tests/fixtures/instances/latest/parcellationEntities/BA-human/BA-human_BA32.jsonld b/tools/tests/fixtures/instances/latest/parcellationEntities/BA-human/BA-human_BA32.jsonld new file mode 100644 index 000000000..71e2b1951 --- /dev/null +++ b/tools/tests/fixtures/instances/latest/parcellationEntities/BA-human/BA-human_BA32.jsonld @@ -0,0 +1,24 @@ +{ + "@context": { + "@vocab": "https://openminds.om-i.org/props/" + }, + "@id": "https://openminds.om-i.org/instances/parcellationEntity/BA-human_BA32", + "@type": "https://openminds.om-i.org/types/ParcellationEntity", + "abbreviation": "BA32", + "alternateName": [ + "Brodmann's area 32", + "human BA32", + "human Brodmann area 32", + "human Brodmann's area 32" + ], + "definition": null, + "hasParent": [ + { + "@id": "https://openminds.om-i.org/instances/parcellationEntity/BA-human_cerebralCortex" + } + ], + "lookupLabel": "BA-human_BA32", + "name": "Brodmann area 32", + "ontologyIdentifier": null, + "relatedInterspeciesAnatomy": null +} diff --git a/tools/tests/fixtures/instances/latest/terminologies/ageCategory/adult.jsonld b/tools/tests/fixtures/instances/latest/terminologies/ageCategory/adult.jsonld new file mode 100644 index 000000000..e17d7fc49 --- /dev/null +++ b/tools/tests/fixtures/instances/latest/terminologies/ageCategory/adult.jsonld @@ -0,0 +1,21 @@ +{ + "@context": { + "@vocab": "https://openminds.om-i.org/props/" + }, + "@id": "https://openminds.om-i.org/instances/ageCategory/adult", + "@type": "https://openminds.om-i.org/types/AgeCategory", + "definition": "'Adult' categorizes the life cycle stage of an animal or human that reached sexual maturity.", + "description": null, + "name": "adult", + "otherCrossReference": null, + "otherOntologyIdentifier": [ + "http://uri.interlex.org/base/ilx_0729043" + ], + "preferredCrossReference": null, + "preferredOntologyIdentifier": "http://purl.obolibrary.org/obo/UBERON_0000113", + "synonym": [ + "adult stage", + "post-juvenile adult", + "post-juvenile adult stage" + ] +} diff --git a/tools/tests/fixtures/instances/latest/terminologies/ageCategory/youngAdult.jsonld b/tools/tests/fixtures/instances/latest/terminologies/ageCategory/youngAdult.jsonld new file mode 100644 index 000000000..ec9fd2275 --- /dev/null +++ b/tools/tests/fixtures/instances/latest/terminologies/ageCategory/youngAdult.jsonld @@ -0,0 +1,19 @@ +{ + "@context": { + "@vocab": "https://openminds.om-i.org/props/" + }, + "@id": "https://openminds.om-i.org/instances/ageCategory/youngAdult", + "@type": "https://openminds.om-i.org/types/AgeCategory", + "definition": "'Young adult' categorizes the early adult stage of an animal or human when sexual maturity has been reached, but not the cessation of growth.", + "description": null, + "name": "young adult", + "otherCrossReference": null, + "otherOntologyIdentifier": null, + "preferredCrossReference": null, + "preferredOntologyIdentifier": null, + "synonym": [ + "early adult", + "early adult stage", + "young adult stage" + ] +} diff --git a/tools/tests/fixtures/instances/latest/terminologies/biologicalSex/male.jsonld b/tools/tests/fixtures/instances/latest/terminologies/biologicalSex/male.jsonld new file mode 100644 index 000000000..c48075dc1 --- /dev/null +++ b/tools/tests/fixtures/instances/latest/terminologies/biologicalSex/male.jsonld @@ -0,0 +1,17 @@ +{ + "@context": { + "@vocab": "https://openminds.om-i.org/props/" + }, + "@id": "https://openminds.om-i.org/instances/biologicalSex/male", + "@type": "https://openminds.om-i.org/types/BiologicalSex", + "definition": "Biological sex that produces sperm cells (spermatozoa).", + "description": "A male organism typically has the capacity to produce relatively small, usually mobile gametes (reproductive cells), called sperm cells (or spermatozoa). In the process of fertilization, these sperm cells fuse with a larger, usually immobile female gamete, called egg cell (or ovum).", + "name": "male", + "otherCrossReference": null, + "otherOntologyIdentifier": [ + "http://uri.interlex.org/base/ilx_0106489" + ], + "preferredCrossReference": null, + "preferredOntologyIdentifier": "http://purl.obolibrary.org/obo/PATO_0000384", + "synonym": null +} diff --git a/tools/tests/unitTests/InstanceLibraryIntegrationTest.m b/tools/tests/unitTests/InstanceLibraryIntegrationTest.m new file mode 100644 index 000000000..8dd928500 --- /dev/null +++ b/tools/tests/unitTests/InstanceLibraryIntegrationTest.m @@ -0,0 +1,132 @@ +classdef InstanceLibraryIntegrationTest < matlab.unittest.TestCase +% InstanceLibraryIntegrationTest - Tests against the downloaded instance library +% +% These read the library the toolbox downloads, so they need it to be +% there and skip when it is not. What they check cannot be checked +% against a library kept with the tests: that every instance upstream +% ships resolves to a type in the model, and that the library follows +% the model version across the versions upstream publishes. + + properties + InstanceLibrary + InstanceTable table + end + + methods (TestClassSetup) + function setupClass(testCase) + testCase.InstanceLibrary = ... + openminds.internal.InstanceLibrary.getSingleton(); + testCase.InstanceTable = testCase.InstanceLibrary.InstanceTable; + + testCase.assumeNotEmpty(testCase.InstanceTable, ... + 'The instance library is not available locally.') + end + end + + methods (Test) + function testEveryInstanceIsTyped(testCase) + % Instance folders are named after pluralized type names, which + % upstream renames whenever a type is renamed. Typing instances by + % folder name therefore went stale silently and left whole modules + % of the library untyped, so no instance may be left without one. + % Only the downloaded library can say whether that holds for what + % upstream ships today. + + untyped = testCase.InstanceTable(testCase.InstanceTable.Type == "", :); + + testCase.verifyEmpty(untyped, sprintf( ... + 'Instances of %d folder(s) were left without a type, e.g. "%s".', ... + numel(unique(fileparts(untyped.Filepath))), ... + strjoin(unique(fileparts(untyped.Filepath))', '", "'))) + end + + function testModuleIsResolvedForEveryInstance(testCase) + % The module used to be assigned alongside the type from the same + % folder classification, so it went stale for the same reason. + + testCase.verifyEmpty( ... + testCase.InstanceTable(testCase.InstanceTable.Module == "", :)) + end + + function testSelectingAModelVersionRebuildsTheLibrary(testCase) + % Instances are typed against the model version that was on the + % path when the library was read, so selecting another version has + % to rebuild the library that is already in memory, in place, for + % the sake of code holding on to it. + + library = testCase.InstanceLibrary; + testCase.assertEqual(library.ModelVersion, openminds.version()) + + testCase.applyFixture(ommtest.helper.ModelVersionFixture("v3.0")) + + testCase.verifyEqual(library.ModelVersion, "v3.0", ... + 'Selecting a model version must rebuild the library in memory.') + + % The version is recorded the way openminds.version reports it, + % so the next use recognizes the library as current instead of + % rebuilding it a second time. + testCase.verifySameHandle( ... + openminds.internal.InstanceLibrary.getSingleton(), library) + end + + function testLibraryVersionFollowsTheModelVersion(testCase) + % The library publishes one set of instances per model version, and + % reading one version's instances against another version's types + % is what leaves instances untyped. The two are therefore not + % chosen separately. + + library = testCase.InstanceLibrary; + testCase.assumeTrue(ismember("v3.0", library.AvailableVersions)) + + testCase.applyFixture(ommtest.helper.ModelVersionFixture("v3.0")) + + testCase.verifyEqual(library.LibraryVersion, "v3.0") + + % The library is downloaded, so a copy that is incomplete for + % this version leaves nothing to check rather than something to + % fail on. + testCase.assumeNotEmpty(library.InstanceTable, ... + 'The instance library holds no instances for this version.') + + % Read from that version's instances, not merely labelled with + % it. Whether every one of them then resolves to a type also + % depends on the model classes having been reloaded, which a + % session that still holds instances of them cannot do, so it + % is not what is checked here. + readFromVersion = contains(library.InstanceTable.Filepath, ... + fullfile(filesep, "v3.0", filesep)); + + testCase.verifyTrue(all(readFromVersion), ... + 'The library must read the instances of the selected version.') + end + + function testModelVersionWithoutInstancesIsReported(testCase) + % Versions 1 and 2 of the model predate the type names the instance + % library is written against, so no version of the library can + % stand in for them. That has to be said rather than left to look + % like a library that happens to be empty. + % + % The version is switched by hand rather than through the fixture + % the other tests use, because the warning is raised by the switch + % itself and verifyWarning has to wrap that call. + + previousModelVersion = openminds.version(); + testCase.addTeardown(@openminds.version, previousModelVersion); + + testCase.verifyWarning(@() openminds.version("v1.0"), ... + 'OPENMINDS:InstanceLibrary:NoInstancesForModelVersion') + + library = testCase.InstanceLibrary; + testCase.verifyTrue(ismissing(library.LibraryVersion), ... + 'No library version can be read for this model version.') + + % The table keeps its columns with nothing in it, so filtering + % it answers with no instances rather than erroring on a table + % that has nothing to filter on. + testCase.verifyEqual(height(library.InstanceTable), 0) + testCase.verifyEqual( ... + string(library.InstanceTable.Properties.VariableNames), ... + ["InstanceName", "Type", "Module", "Subgroup", "Filepath"]) + end + end +end diff --git a/tools/tests/unitTests/InstanceLibraryTest.m b/tools/tests/unitTests/InstanceLibraryTest.m index 7afc1c89d..d7e707e29 100644 --- a/tools/tests/unitTests/InstanceLibraryTest.m +++ b/tools/tests/unitTests/InstanceLibraryTest.m @@ -1,205 +1,146 @@ classdef InstanceLibraryTest < matlab.unittest.TestCase % InstanceLibraryTest - Unit tests for the openMINDS instance library +% +% These read a small library kept with the tests, through +% ommtest.helper.InstanceLibraryFixture, so they need no download and +% check known instances. What can only be checked against the +% downloaded library is in InstanceLibraryIntegrationTest. properties - InstanceLibrary - InstanceTable table + Fixture end - methods (TestClassSetup) - function setupClass(testCase) - testCase.InstanceLibrary = ... - openminds.internal.InstanceLibrary.getSingleton(); - testCase.InstanceTable = testCase.InstanceLibrary.InstanceTable; - - testCase.assumeNotEmpty(testCase.InstanceTable, ... - 'The instance library is not available locally.') + methods (TestMethodSetup) + function readTheTestLibrary(testCase) + testCase.Fixture = ... + testCase.applyFixture(ommtest.helper.InstanceLibraryFixture); end end methods (Test) - function testEveryInstanceIsTyped(testCase) - % Instances used to be typed by folder name. Folder names are - % pluralized type names that upstream renames whenever a type is - % renamed, so that typing went stale silently and left whole - % modules of the library untyped. Every instance must have a type. - - untyped = testCase.InstanceTable(testCase.InstanceTable.Type == "", :); - - testCase.verifyEmpty(untyped, sprintf( ... - 'Instances of %d folder(s) were left without a type, e.g. "%s".', ... - numel(unique(fileparts(untyped.Filepath))), ... - strjoin(unique(fileparts(untyped.Filepath))', '", "'))) - end + function testInstancesAreTypedByWhatTheyDeclare(testCase) + % Every instance in the test library is typed by the "@type" its + % document declares, and its module follows from the type. The + % folder names differ from the type names, in the plural and in + % case, so nothing here can be read off a folder. - function testTypeIsTakenFromTheInstanceAndNotTheFolderName(testCase) - % License instances are stored in a folder named "licenses". No - % capitalization of that folder name gives the type name "License", - % so the type must come from the "@type" the instance declares. + expected = table( ... + ["adult"; "youngAdult"; "male"; "MIT"; "text_plain"; "BA-human_BA32"], ... + ["AgeCategory"; "AgeCategory"; "BiologicalSex"; "License"; ... + "ContentType"; "ParcellationEntity"], ... + ["controlledTerms"; "controlledTerms"; "controlledTerms"; "core"; ... + "core"; "SANDS"], ... + 'VariableNames', ["InstanceName", "Type", "Module"]); - licenses = testCase.instancesOfType("License"); - testCase.assumeNotEmpty(licenses) + actual = testCase.Fixture.Library.InstanceTable( ... + :, ["InstanceName", "Type", "Module"]); - [~, folderNames] = fileparts(fileparts(licenses.Filepath)); - - testCase.verifyTrue(all(folderNames == "licenses"), ... - 'Expected License instances to be stored in the "licenses" folder.') - end - - function testModuleIsResolvedForEveryInstance(testCase) - % The module used to be derived from the folder name together with - % the type, so it went stale for the same reason. - - testCase.verifyEmpty( ... - testCase.InstanceTable(testCase.InstanceTable.Module == "", :)) + testCase.verifyEqual(sortrows(actual), sortrows(expected)) end function testInstancesGroupedInASubfolderCarryTheSubgroup(testCase) - % Parcellation entities are split into one subfolder per atlas, - % e.g. parcellationEntities/BA-human. The subfolder is the subgroup. + % Parcellation entities are grouped per atlas, here in + % parcellationEntities/BA-human. That grouping is the subgroup. - entities = testCase.instancesOfType("ParcellationEntity"); - testCase.assumeNotEmpty(entities) + entity = testCase.instanceNamed("BA-human_BA32"); - testCase.verifyFalse(any(ismissing(entities.Subgroup)), ... - 'Instances grouped in a subfolder must carry that subgroup.') + testCase.verifyEqual(entity.Subgroup, "BA-human") end function testATypeFolderIsNotReportedAsASubgroup(testCase) - % Controlled terms are stored one type per subfolder under - % terminologies, e.g. terminologies/ageCategory. That subfolder - % names a type, not a subgroup. The two cases are told apart by - % whether sibling folders hold the same type. + % Controlled terms are stored one type per folder under + % terminologies. That folder names the type, not a subgroup, and + % the two are told apart by whether sibling folders share a type: + % ageCategory and biologicalSex do not. - ageCategories = testCase.instancesOfType("AgeCategory"); - testCase.assumeNotEmpty(ageCategories) + term = testCase.instanceNamed("adult"); - testCase.verifyTrue(all(ismissing(ageCategories.Subgroup)), ... + testCase.verifyTrue(ismissing(term.Subgroup), ... 'A folder that names a type must not be read as a subgroup.') end function testPluralIRISegmentResolvesToItsType(testCase) % A few instance IRIs name their type in the plural. openMINDS - % publishes no plural-to-singular mapping, so the library reads the - % mapping from the instance documents. + % publishes no plural to singular mapping, so the segments are + % collected from the instances themselves. - typeEnum = testCase.InstanceLibrary.getTypeFromIRISegment("licenses"); + library = testCase.Fixture.Library; - testCase.verifyEqual(typeEnum, openminds.enum.Types("License")) + testCase.verifyEqual(library.getTypeFromIRISegment("licenses"), ... + openminds.enum.Types("License")) + testCase.verifyEqual(library.getTypeFromIRISegment("contentTypes"), ... + openminds.enum.Types("ContentType")) end function testSingularIRISegmentResolvesToItsType(testCase) - typeEnum = ... - testCase.InstanceLibrary.getTypeFromIRISegment("parcellationEntity"); + library = testCase.Fixture.Library; - testCase.verifyEqual(typeEnum, openminds.enum.Types("ParcellationEntity")) + testCase.verifyEqual(library.getTypeFromIRISegment("ageCategory"), ... + openminds.enum.Types("AgeCategory")) + testCase.verifyEqual(library.getTypeFromIRISegment("parcellationEntity"), ... + openminds.enum.Types("ParcellationEntity")) end - function testSelectingAModelVersionRebuildsTheLibrary(testCase) - % The instance table is typed against the model version on the path - % when it was built, so selecting another version must rebuild the - % library object already in memory. It must be rebuilt in place, - % because other code may hold a reference to the object. - - library = testCase.InstanceLibrary; - testCase.assertEqual(library.ModelVersion, openminds.version()) - - testCase.applyFixture(ommtest.helper.ModelVersionFixture("v3.0")) - - testCase.verifyEqual(library.ModelVersion, "v3.0", ... - 'Selecting a model version must rebuild the library in memory.') + function testUnknownIRISegmentIsRejected(testCase) + % A segment that names no type in the library must raise this + % specific error, so that a caller can tell a bad IRI from a + % missing library. - % ModelVersion is stored in the format openminds.version - % reports, so getSingleton must recognize the rebuilt library - % as current and return the same handle instead of creating a - % new one. - testCase.verifySameHandle( ... - openminds.internal.InstanceLibrary.getSingleton(), library) + testCase.verifyError( ... + @() testCase.Fixture.Library.getTypeFromIRISegment("notASegment"), ... + 'OPENMINDS:InstanceLibrary:UnknownIRISegment') end - function testLibraryVersionFollowsTheModelVersion(testCase) - % The library publishes one set of instances per model version, and - % reading one version's instances against another version's classes - % leaves instances untyped, so the library version must equal the - % model version. - - library = testCase.InstanceLibrary; - testCase.assumeTrue(ismember("v3.0", library.AvailableVersions)) - - testCase.applyFixture(ommtest.helper.ModelVersionFixture("v3.0")) + function testADocumentWithoutATypeIsReported(testCase) + % A document whose "@type" cannot be read leaves its folder + % untyped. That is reported, naming the document, rather than + % skipped in silence like a folder that was never there. - testCase.verifyEqual(library.LibraryVersion, "v3.0") + folder = testCase.Fixture.Folder; + damagedFolder = fullfile(folder, "latest", "damaged"); + mkdir(damagedFolder) + fileId = fopen(fullfile(damagedFolder, "broken.jsonld"), "w"); + fprintf(fileId, "{ not a document"); + fclose(fileId); - % The library is downloaded, and a copy may hold no instances - % for this version. That is not a failure of the code under - % test. - testCase.assumeNotEmpty(library.InstanceTable, ... - 'The instance library holds no instances for this version.') + library = testCase.verifyWarning( ... + @() openminds.internal.InstanceLibrary.getSingleton(folder, "Reset", true), ... + 'OPENMINDS:InstanceLibrary:UnreadableInstance'); - % Check that the instances were read from the v3.0 folder, not - % merely labelled v3.0. Whether every instance then resolves to - % a type depends on the model classes having been reloaded, - % which MATLAB cannot do while objects of those classes exist - % in the session, so that is not checked here. - readFromVersion = contains(library.InstanceTable.Filepath, ... - fullfile(filesep, "v3.0", filesep)); - - testCase.verifyTrue(all(readFromVersion), ... - 'The library must read the instances of the selected version.') + broken = library.InstanceTable(library.InstanceTable.InstanceName == "broken", :); + testCase.verifyEqual(broken.Type, "", ... + 'The damaged document must be listed without a type.') + testCase.verifyEqual(nnz(library.InstanceTable.Type ~= ""), 6, ... + 'The other documents must be typed as before.') end - function testModelVersionWithoutInstancesIsReported(testCase) - % Model versions 1 and 2 predate the type names the instance library - % uses, so no library version can serve them. This must be reported - % as a warning rather than look like a library that happens to be - % empty. - % - % The version is switched directly rather than through - % ModelVersionFixture because verifyWarning has to wrap the call - % that raises the warning. - - previousModelVersion = openminds.version(); - testCase.addTeardown(@openminds.version, previousModelVersion); + function testAVersionWithoutInstancesIsReported(testCase) + % A version folder that holds no instance files reads as an empty + % library, with its columns in place, and says so. Nothing here + % may fail: selecting a model version rebuilds the library, and a + % library that cannot be read is no reason for that to fail. - testCase.verifyWarning(@() openminds.version("v1.0"), ... - 'OPENMINDS:InstanceLibrary:NoInstancesForModelVersion') + folder = testCase.Fixture.Folder; + rmdir(fullfile(folder, "latest"), "s") + mkdir(fullfile(folder, "latest")) - library = testCase.InstanceLibrary; - testCase.verifyTrue(ismissing(library.LibraryVersion), ... - 'No library version can be read for this model version.') + library = testCase.verifyWarning( ... + @() openminds.internal.InstanceLibrary.getSingleton(folder, "Reset", true), ... + 'OPENMINDS:InstanceLibrary:InstancesNotFound'); - % The table keeps its columns, so filtering it returns no rows - % instead of erroring. testCase.verifyEqual(height(library.InstanceTable), 0) testCase.verifyEqual( ... string(library.InstanceTable.Properties.VariableNames), ... ["InstanceName", "Type", "Module", "Subgroup", "Filepath"]) end - function testLibraryLocationSurvivesAWorkingDirectoryChange(testCase) - % The location is built under userpath. On a CI runner userpath is - % empty, because $HOME/Documents does not exist, and a path built - % under an empty userpath is relative. The library is read again on - % every model version change, so the location must stay valid after - % the working directory changes. - - library = testCase.InstanceLibrary; - testCase.assumeTrue(isfolder(library.InstanceLibraryLocation)) - - testCase.applyFixture( ... - matlab.unittest.fixtures.WorkingFolderFixture) - - testCase.verifyTrue(isfolder(library.InstanceLibraryLocation), ... - 'The location must still name the library from another folder.') - end - function testAMissingLocationIsRejectedByName(testCase) % The library is downloaded into its default location only, so a % location a caller names has to exist already. It is rejected % before the library in use is touched, so that library survives % the mistake. - library = testCase.InstanceLibrary; + fixture = testCase.Fixture; testCase.verifyError( ... @() openminds.internal.InstanceLibrary.getSingleton( ... @@ -207,7 +148,8 @@ function testAMissingLocationIsRejectedByName(testCase) 'OPENMINDS:InstanceLibrary:LocationNotFound') testCase.verifySameHandle( ... - openminds.internal.InstanceLibrary.getSingleton(), library) + openminds.internal.InstanceLibrary.getSingleton(fixture.Folder), ... + fixture.Library) end function testAFolderWithoutVersionsIsRejectedByName(testCase) @@ -217,7 +159,7 @@ function testAFolderWithoutVersionsIsRejectedByName(testCase) import matlab.unittest.fixtures.TemporaryFolderFixture - library = testCase.InstanceLibrary; + fixture = testCase.Fixture; emptyFolder = testCase.applyFixture(TemporaryFolderFixture).Folder; testCase.verifyError( ... @@ -225,24 +167,34 @@ function testAFolderWithoutVersionsIsRejectedByName(testCase) 'OPENMINDS:InstanceLibrary:LocationNotFound') testCase.verifySameHandle( ... - openminds.internal.InstanceLibrary.getSingleton(), library) + openminds.internal.InstanceLibrary.getSingleton(fixture.Folder), ... + fixture.Library) end - function testUnknownIRISegmentIsRejected(testCase) - % A segment that names no type in the library must raise this - % specific error, so that a caller can tell a bad IRI from a - % missing library. + function testLibraryLocationSurvivesAWorkingDirectoryChange(testCase) + % The location is built under userpath. On a CI runner userpath is + % empty, because $HOME/Documents does not exist, and a path built + % under an empty userpath is relative. The library is read again on + % every model version change, so the location must stay valid after + % the working directory changes. - testCase.verifyError( ... - @() testCase.InstanceLibrary.getTypeFromIRISegment("notASegment"), ... - 'OPENMINDS:InstanceLibrary:UnknownIRISegment') + library = testCase.Fixture.Library; + testCase.assertTrue(isfolder(library.InstanceLibraryLocation)) + + testCase.applyFixture( ... + matlab.unittest.fixtures.WorkingFolderFixture) + + testCase.verifyTrue(isfolder(library.InstanceLibraryLocation), ... + 'The location must still name the library from another folder.') end end methods (Access = private) - function instances = instancesOfType(testCase, typeName) - instances = testCase.InstanceTable( ... - testCase.InstanceTable.Type == typeName, :); + function row = instanceNamed(testCase, instanceName) + instanceTable = testCase.Fixture.Library.InstanceTable; + row = instanceTable(instanceTable.InstanceName == instanceName, :); + testCase.assertEqual(height(row), 1, ... + sprintf('Expected one instance named "%s" in the test library.', instanceName)) end end end From 66dcf6b1269f532f67035a959d02a31e064100f8 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 13:49:53 +0200 Subject: [PATCH 2/3] docs: say why an unreadable library warns instead of erroring Co-Authored-By: Claude Fable 5.1 --- tools/tests/unitTests/InstanceLibraryTest.m | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/tests/unitTests/InstanceLibraryTest.m b/tools/tests/unitTests/InstanceLibraryTest.m index d7e707e29..d0edbf375 100644 --- a/tools/tests/unitTests/InstanceLibraryTest.m +++ b/tools/tests/unitTests/InstanceLibraryTest.m @@ -116,9 +116,10 @@ function testADocumentWithoutATypeIsReported(testCase) function testAVersionWithoutInstancesIsReported(testCase) % A version folder that holds no instance files reads as an empty - % library, with its columns in place, and says so. Nothing here - % may fail: selecting a model version rebuilds the library, and a - % library that cannot be read is no reason for that to fail. + % library, with its columns in place, and warns rather than + % errors. Selecting a model version rebuilds the library the same + % way, and a library that cannot be read must not stop the + % selection. folder = testCase.Fixture.Folder; rmdir(fullfile(folder, "latest"), "s") From 75c3ca515aeeab9f4b72715e6cd6f71adf9170de Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 11 Sep 2026 14:15:47 +0200 Subject: [PATCH 3/3] docs: say plainly what the integration tests check and why The tests moved here kept the comments they had before the comments on the branch below were rewritten. They now state the mechanism first and what is asserted second, in the same wording as the unit tests. Co-Authored-By: Claude Fable 5.1 --- .../InstanceLibraryIntegrationTest.m | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/tools/tests/unitTests/InstanceLibraryIntegrationTest.m b/tools/tests/unitTests/InstanceLibraryIntegrationTest.m index 8dd928500..87f3c5a89 100644 --- a/tools/tests/unitTests/InstanceLibraryIntegrationTest.m +++ b/tools/tests/unitTests/InstanceLibraryIntegrationTest.m @@ -25,12 +25,12 @@ function setupClass(testCase) methods (Test) function testEveryInstanceIsTyped(testCase) - % Instance folders are named after pluralized type names, which - % upstream renames whenever a type is renamed. Typing instances by - % folder name therefore went stale silently and left whole modules - % of the library untyped, so no instance may be left without one. - % Only the downloaded library can say whether that holds for what - % upstream ships today. + % Instances used to be typed by folder name. Folder names are + % pluralized type names that upstream renames whenever a type is + % renamed, so that typing went stale silently and left whole + % modules of the library untyped. Every instance must have a type, + % and only the downloaded library can show that this holds for + % what upstream ships today. untyped = testCase.InstanceTable(testCase.InstanceTable.Type == "", :); @@ -41,18 +41,18 @@ function testEveryInstanceIsTyped(testCase) end function testModuleIsResolvedForEveryInstance(testCase) - % The module used to be assigned alongside the type from the same - % folder classification, so it went stale for the same reason. + % The module used to be derived from the folder name together with + % the type, so it went stale for the same reason. testCase.verifyEmpty( ... testCase.InstanceTable(testCase.InstanceTable.Module == "", :)) end function testSelectingAModelVersionRebuildsTheLibrary(testCase) - % Instances are typed against the model version that was on the - % path when the library was read, so selecting another version has - % to rebuild the library that is already in memory, in place, for - % the sake of code holding on to it. + % The instance table is typed against the model version on the path + % when it was built, so selecting another version must rebuild the + % library object already in memory. It must be rebuilt in place, + % because other code may hold a reference to the object. library = testCase.InstanceLibrary; testCase.assertEqual(library.ModelVersion, openminds.version()) @@ -62,18 +62,19 @@ function testSelectingAModelVersionRebuildsTheLibrary(testCase) testCase.verifyEqual(library.ModelVersion, "v3.0", ... 'Selecting a model version must rebuild the library in memory.') - % The version is recorded the way openminds.version reports it, - % so the next use recognizes the library as current instead of - % rebuilding it a second time. + % ModelVersion is stored in the format openminds.version + % reports, so getSingleton must recognize the rebuilt library + % as current and return the same handle instead of creating a + % new one. testCase.verifySameHandle( ... openminds.internal.InstanceLibrary.getSingleton(), library) end function testLibraryVersionFollowsTheModelVersion(testCase) % The library publishes one set of instances per model version, and - % reading one version's instances against another version's types - % is what leaves instances untyped. The two are therefore not - % chosen separately. + % reading one version's instances against another version's classes + % leaves instances untyped, so the library version must equal the + % model version. library = testCase.InstanceLibrary; testCase.assumeTrue(ismember("v3.0", library.AvailableVersions)) @@ -82,17 +83,17 @@ function testLibraryVersionFollowsTheModelVersion(testCase) testCase.verifyEqual(library.LibraryVersion, "v3.0") - % The library is downloaded, so a copy that is incomplete for - % this version leaves nothing to check rather than something to - % fail on. + % The library is downloaded, and a copy may hold no instances + % for this version. That is not a failure of the code under + % test. testCase.assumeNotEmpty(library.InstanceTable, ... 'The instance library holds no instances for this version.') - % Read from that version's instances, not merely labelled with - % it. Whether every one of them then resolves to a type also - % depends on the model classes having been reloaded, which a - % session that still holds instances of them cannot do, so it - % is not what is checked here. + % Check that the instances were read from the v3.0 folder, not + % merely labelled v3.0. Whether every instance then resolves to + % a type depends on the model classes having been reloaded, + % which MATLAB cannot do while objects of those classes exist + % in the session, so that is not checked here. readFromVersion = contains(library.InstanceTable.Filepath, ... fullfile(filesep, "v3.0", filesep)); @@ -101,14 +102,14 @@ function testLibraryVersionFollowsTheModelVersion(testCase) end function testModelVersionWithoutInstancesIsReported(testCase) - % Versions 1 and 2 of the model predate the type names the instance - % library is written against, so no version of the library can - % stand in for them. That has to be said rather than left to look - % like a library that happens to be empty. + % Model versions 1 and 2 predate the type names the instance library + % uses, so no library version can serve them. This must be reported + % as a warning rather than look like a library that happens to be + % empty. % - % The version is switched by hand rather than through the fixture - % the other tests use, because the warning is raised by the switch - % itself and verifyWarning has to wrap that call. + % The version is switched directly rather than through + % ModelVersionFixture because verifyWarning has to wrap the call + % that raises the warning. previousModelVersion = openminds.version(); testCase.addTeardown(@openminds.version, previousModelVersion); @@ -120,9 +121,8 @@ function testModelVersionWithoutInstancesIsReported(testCase) testCase.verifyTrue(ismissing(library.LibraryVersion), ... 'No library version can be read for this model version.') - % The table keeps its columns with nothing in it, so filtering - % it answers with no instances rather than erroring on a table - % that has nothing to filter on. + % The table keeps its columns, so filtering it returns no rows + % instead of erroring. testCase.verifyEqual(height(library.InstanceTable), 0) testCase.verifyEqual( ... string(library.InstanceTable.Properties.VariableNames), ...