diff --git a/js/mappingXmlToInputFields.js b/js/mappingXmlToInputFields.js
index 96a700735..63a77a23c 100644
--- a/js/mappingXmlToInputFields.js
+++ b/js/mappingXmlToInputFields.js
@@ -3,10 +3,19 @@
* in the dropdown based on the visible text matching the `resourceTypeGeneral` attribute.
*
* @param {Document} xmlDoc - The XML document containing the resourceType element.
+ * @param {Function} resolver - The namespace resolver function.
*/
-function processResourceType(xmlDoc) {
- // Extract the resourceType element
- const resourceNode = xmlDoc.querySelector("resourceType");
+function processResourceType(xmlDoc, resolver) {
+ // Extract the resourceType element using XPath with namespace fallback
+ // (supports both namespaced and non-namespaced XML documents)
+ const result = xmlDoc.evaluate(
+ ".//ns:resourceType | .//resourceType",
+ xmlDoc,
+ resolver,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ );
+ const resourceNode = result.singleNodeValue;
if (!resourceNode) {
console.error("No resourceType element found in XML");
return;
@@ -390,8 +399,17 @@ function processContactPersons(xmlDoc) {
.first();
if ($row.length === 0) {
- console.warn(`No matching author row found for contact person: ${givenName} ${familyName}`);
- continue;
+ // No matching author found — add a new author row for the contact person
+ const countBefore = $("div[data-creator-row]").length;
+ $("#button-author-add").click();
+ const countAfter = $("div[data-creator-row]").length;
+ if (countAfter <= countBefore) {
+ console.warn("Could not create new author row for contact person:", familyName, givenName);
+ continue;
+ }
+ $row = $("div[data-creator-row]").last();
+ $row.find('input[name="familynames[]"]').val(familyName);
+ $row.find('input[name="givennames[]"]').val(givenName);
}
// Mark the row as contact person
@@ -451,7 +469,19 @@ function processContactPersonsFromDataCite(xmlDoc) {
})
.first();
- if ($row.length === 0) continue;
+ if ($row.length === 0) {
+ // No matching author found — add a new author row for the contact person
+ const countBefore = $("div[data-creator-row]").length;
+ $("#button-author-add").click();
+ const countAfter = $("div[data-creator-row]").length;
+ if (countAfter <= countBefore) {
+ console.warn("Could not create new author row for contact person:", familyName, givenName);
+ continue;
+ }
+ $row = $("div[data-creator-row]").last();
+ $row.find('input[name="familynames[]"]').val(familyName);
+ $row.find('input[name="givennames[]"]').val(givenName);
+ }
$row.find('input[name="contacts[]"]').prop("checked", true);
$row.find(".contact-person-input").show();
@@ -914,26 +944,38 @@ function parseTemporalData(dateNode) {
/**
* Extract spatial coordinates and description from a geoLocation node.
* @param {Element} node - The geoLocation XML element.
+ * @param {Document} xmlDoc - The XML document (needed for XPath evaluation).
+ * @param {Function} resolver - The namespace resolver function.
* @returns {Object} Parsed location data.
*/
-function getGeoLocationData(node) {
- const place = node.querySelector("geoLocationPlace")?.textContent || "";
- const boxNode = node.querySelector("geoLocationBox");
- const pointNode = node.querySelector("geoLocationPoint");
+function getGeoLocationData(node, xmlDoc, resolver) {
+ function getText(contextNode, localName) {
+ const result = xmlDoc.evaluate("ns:" + localName + " | " + localName, contextNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+ return result.singleNodeValue?.textContent || "";
+ }
+
+ function getNode(contextNode, localName) {
+ const result = xmlDoc.evaluate("ns:" + localName + " | " + localName, contextNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
+ return result.singleNodeValue;
+ }
+
+ const place = getText(node, "geoLocationPlace");
+ const boxNode = getNode(node, "geoLocationBox");
+ const pointNode = getNode(node, "geoLocationPoint");
if (boxNode) {
return {
place,
- latitudeMin: boxNode.querySelector("southBoundLatitude")?.textContent || "",
- latitudeMax: boxNode.querySelector("northBoundLatitude")?.textContent || "",
- longitudeMin: boxNode.querySelector("westBoundLongitude")?.textContent || "",
- longitudeMax: boxNode.querySelector("eastBoundLongitude")?.textContent || "",
+ latitudeMin: getText(boxNode, "southBoundLatitude"),
+ latitudeMax: getText(boxNode, "northBoundLatitude"),
+ longitudeMin: getText(boxNode, "westBoundLongitude"),
+ longitudeMax: getText(boxNode, "eastBoundLongitude"),
};
}
if (pointNode) {
- const lat = pointNode.querySelector("pointLatitude")?.textContent || "";
- const lon = pointNode.querySelector("pointLongitude")?.textContent || "";
+ const lat = getText(pointNode, "pointLatitude");
+ const lon = getText(pointNode, "pointLongitude");
return {
place,
latitudeMin: lat,
@@ -1000,11 +1042,11 @@ function fillTemporalFields($row, temporalData) {
* @param {Function} resolver - The namespace resolver function.
*/
function processSpatialTemporalCoverages(xmlDoc, resolver) {
- const geoLocationNodes = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation", xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
- const dateNodes = xmlDoc.evaluate('//ns:dates/ns:date[@dateType="Coverage" or @dateType="Collected"]', xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ const geoLocationNodes = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation | .//geoLocations/geoLocation", xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ const dateNodes = xmlDoc.evaluate('//ns:dates/ns:date[@dateType="Coverage" or @dateType="Collected"] | //dates/date[@dateType="Coverage" or @dateType="Collected"]', xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (let i = 0; i < geoLocationNodes.snapshotLength; i++) {
- const geoData = getGeoLocationData(geoLocationNodes.snapshotItem(i));
+ const geoData = getGeoLocationData(geoLocationNodes.snapshotItem(i), xmlDoc, resolver);
const temporalData = parseTemporalData(dateNodes.snapshotItem(i));
const $lastRow = $('textarea[name="tscDescription[]"]').last().closest("[tsc-row]");
@@ -1280,16 +1322,17 @@ function processUsedInstruments(xmlDoc, resolver) {
*/
function processFunders(xmlDoc, resolver) {
// Fetch all fundingReference nodes
- const funderNodes = xmlDoc.evaluate(".//ns:fundingReferences/ns:fundingReference", xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ const funderNodes = xmlDoc.evaluate(".//ns:fundingReferences/ns:fundingReference | .//fundingReferences/fundingReference", xmlDoc, resolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (let i = 0; i < funderNodes.snapshotLength; i++) {
const funderNode = funderNodes.snapshotItem(i);
// Extract data from XML
- const funderName = getNodeText(funderNode, "ns:funderName", xmlDoc, resolver);
- const funderId = getNodeText(funderNode, "ns:funderIdentifier", xmlDoc, resolver);
- const funderIdTyp = funderNode.querySelector("funderIdentifier")?.getAttribute("funderIdentifierType") || "";
- const awardTitle = getNodeText(funderNode, "ns:awardTitle", xmlDoc, resolver);
- const awardNumberNode = xmlDoc.evaluate("ns:awardNumber", funderNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
+ const funderName = getNodeText(funderNode, "ns:funderName | funderName", xmlDoc, resolver);
+ const funderIdNode = xmlDoc.evaluate("ns:funderIdentifier | funderIdentifier", funderNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
+ const funderId = funderIdNode ? funderIdNode.textContent.trim() : "";
+ const funderIdTyp = funderIdNode?.getAttribute("funderIdentifierType") || "";
+ const awardTitle = getNodeText(funderNode, "ns:awardTitle | awardTitle", xmlDoc, resolver);
+ const awardNumberNode = xmlDoc.evaluate("ns:awardNumber | awardNumber", funderNode, resolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
const awardNumber = awardNumberNode ? awardNumberNode.textContent.trim() : "";
const awardUri = awardNumberNode?.getAttribute("awardURI") || "";
@@ -1410,7 +1453,7 @@ async function loadXmlToForm(xmlDoc) {
}
}
- processResourceType(xmlDoc);
+ processResourceType(xmlDoc, resolver);
// Process titles
processTitles(xmlDoc, resolver, titleTypeMapping);
// Processing Creators
@@ -1469,6 +1512,10 @@ if (typeof module !== 'undefined' && module.exports) {
parseTemporalData,
getGeoLocationData,
fillSpatialFields,
- processUsedInstruments
+ processUsedInstruments,
+ processDescriptions,
+ processRelatedWorks,
+ processFunders,
+ processSpatialTemporalCoverages
};
}
diff --git a/tests/js/contactPersonLoading.test.js b/tests/js/contactPersonLoading.test.js
index a14d49e83..8c7392194 100644
--- a/tests/js/contactPersonLoading.test.js
+++ b/tests/js/contactPersonLoading.test.js
@@ -42,9 +42,29 @@ describe('processContactPersons (ISO)', () => {
+
`;
+ // Wire up click handler to simulate adding a new author row
+ $('#button-author-add').on('click', function () {
+ const $newRow = $(`
+
+ `);
+ $('#group-author').append($newRow);
+ });
+
global.Tagify = jest.fn().mockImplementation(() => ({
addTags: jest.fn(),
settings: { whitelist: [] },
@@ -175,7 +195,7 @@ describe('processContactPersons (ISO)', () => {
expect($firstRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
});
- test('does not match when names differ completely', () => {
+ test('creates new author row when names differ completely', () => {
const xmlDoc = makeIsoXml({
familyName: 'Unknown',
givenName: 'Person',
@@ -184,10 +204,20 @@ describe('processContactPersons (ISO)', () => {
mappingModule.processContactPersons(xmlDoc);
- // Neither row should be marked as CP
- $('div[data-creator-row]').each(function () {
- expect($(this).find('input[name="contacts[]"]').prop('checked')).toBe(false);
- });
+ // A third author row should have been created
+ const $rows = $('div[data-creator-row]');
+ expect($rows.length).toBe(3);
+
+ // Original rows should not be marked as CP
+ expect($rows.eq(0).find('input[name="contacts[]"]').prop('checked')).toBe(false);
+ expect($rows.eq(1).find('input[name="contacts[]"]').prop('checked')).toBe(false);
+
+ // New row should be marked as CP with correct data
+ const $newRow = $rows.eq(2);
+ expect($newRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
+ expect($newRow.find('input[name="familynames[]"]').val()).toBe('Unknown');
+ expect($newRow.find('input[name="givennames[]"]').val()).toBe('Person');
+ expect($newRow.find('input[name="cpEmail[]"]').val()).toBe('unknown@example.com');
});
test('handles XML with no pointOfContact gracefully', () => {
@@ -309,9 +339,28 @@ describe('processContactPersonsFromDataCite (fallback)', () => {
+
`;
+ // Wire up click handler to simulate adding a new author row
+ $('#button-author-add').on('click', function () {
+ const $newRow = $(`
+
+ `);
+ $('#group-author').append($newRow);
+ });
+
global.Tagify = jest.fn().mockImplementation(() => ({
addTags: jest.fn(),
settings: { whitelist: [] },
@@ -407,7 +456,7 @@ describe('processContactPersonsFromDataCite (fallback)', () => {
expect($firstRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
});
- test('does nothing when no matching author found in DataCite fallback', () => {
+ test('creates new author row when no matching author found in DataCite fallback', () => {
const xmlDoc = makeDataCiteXml({
familyName: 'Unknown',
givenName: 'Person',
@@ -415,9 +464,18 @@ describe('processContactPersonsFromDataCite (fallback)', () => {
expect(() => mappingModule.processContactPersonsFromDataCite(xmlDoc)).not.toThrow();
- $('div[data-creator-row]').each(function () {
- expect($(this).find('input[name="contacts[]"]').prop('checked')).toBe(false);
- });
+ // A second author row should have been created
+ const $rows = $('div[data-creator-row]');
+ expect($rows.length).toBe(2);
+
+ // Original row should not be marked as CP
+ expect($rows.eq(0).find('input[name="contacts[]"]').prop('checked')).toBe(false);
+
+ // New row should be marked as CP with correct data
+ const $newRow = $rows.eq(1);
+ expect($newRow.find('input[name="contacts[]"]').prop('checked')).toBe(true);
+ expect($newRow.find('input[name="familynames[]"]').val()).toBe('Unknown');
+ expect($newRow.find('input[name="givennames[]"]').val()).toBe('Person');
});
test('does nothing when DataCite XML has no ContactPerson contributor', () => {
diff --git a/tests/js/mappingXmlToInputFields.module.test.js b/tests/js/mappingXmlToInputFields.module.test.js
index e2bf8dd87..6a55f298f 100644
--- a/tests/js/mappingXmlToInputFields.module.test.js
+++ b/tests/js/mappingXmlToInputFields.module.test.js
@@ -438,12 +438,17 @@ describe('mappingXmlToInputFields module coverage', () => {
});
describe('getGeoLocationData', () => {
+ const nsResolver = (prefix) => prefix === 'ns' ? 'http://datacite.org/schema/kernel-4' : null;
+
test('returns empty data for node without location info', () => {
const parser = new DOMParser();
- const xmlDoc = parser.parseFromString('', 'text/xml');
- const geoNode = xmlDoc.querySelector('geoLocation');
+ const xmlDoc = parser.parseFromString(
+ '',
+ 'text/xml'
+ );
+ const geoNode = xmlDoc.documentElement;
- const result = mappingModule.getGeoLocationData(geoNode);
+ const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
expect(result.latitudeMin).toBe('');
expect(result.longitudeMin).toBe('');
});
@@ -451,29 +456,29 @@ describe('mappingXmlToInputFields module coverage', () => {
test('extracts place name', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
- 'Berlin',
+ 'Berlin',
'text/xml'
);
- const geoNode = xmlDoc.querySelector('geoLocation');
+ const geoNode = xmlDoc.documentElement;
- const result = mappingModule.getGeoLocationData(geoNode);
+ const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
expect(result.place).toBe('Berlin');
});
test('extracts point coordinates', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
- `
-
- 52.5
- 13.4
-
- `,
+ `
+
+ 52.5
+ 13.4
+
+ `,
'text/xml'
);
- const geoNode = xmlDoc.querySelector('geoLocation');
+ const geoNode = xmlDoc.documentElement;
- const result = mappingModule.getGeoLocationData(geoNode);
+ const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
// For point, lat/lon are duplicated to min and max
expect(result.latitudeMin).toBe('52.5');
expect(result.latitudeMax).toBe('52.5');
@@ -484,19 +489,19 @@ describe('mappingXmlToInputFields module coverage', () => {
test('extracts bounding box coordinates', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
- `
-
- 53
- 52
- 14
- 13
-
- `,
+ `
+
+ 53
+ 52
+ 14
+ 13
+
+ `,
'text/xml'
);
- const geoNode = xmlDoc.querySelector('geoLocation');
+ const geoNode = xmlDoc.documentElement;
- const result = mappingModule.getGeoLocationData(geoNode);
+ const result = mappingModule.getGeoLocationData(geoNode, xmlDoc, nsResolver);
expect(result.latitudeMin).toBe('52');
expect(result.latitudeMax).toBe('53');
expect(result.longitudeMin).toBe('13');
@@ -541,36 +546,38 @@ describe('mappingXmlToInputFields module coverage', () => {
});
describe('processResourceType', () => {
+ const nsResolver = (prefix) => prefix === 'ns' ? 'http://datacite.org/schema/kernel-4' : null;
+
test('handles missing resourceType node', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString('', 'text/xml');
// Should not throw
expect(() => {
- mappingModule.processResourceType(xmlDoc);
+ mappingModule.processResourceType(xmlDoc, nsResolver);
}).not.toThrow();
});
test('handles missing resourceTypeGeneral attribute', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
- 'Research Data',
+ 'Research Data',
'text/xml'
);
expect(() => {
- mappingModule.processResourceType(xmlDoc);
+ mappingModule.processResourceType(xmlDoc, nsResolver);
}).not.toThrow();
});
test('selects matching option in dropdown', () => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(
- 'Research Data',
+ 'Research Data',
'text/xml'
);
- mappingModule.processResourceType(xmlDoc);
+ mappingModule.processResourceType(xmlDoc, nsResolver);
const select = document.querySelector('#input-resourceinformation-resourcetype');
expect(select.options[0].selected).toBe(true);
diff --git a/tests/js/mappingXmlToInputFields.test.js b/tests/js/mappingXmlToInputFields.test.js
index 0bb2f41ba..7e00741d9 100644
--- a/tests/js/mappingXmlToInputFields.test.js
+++ b/tests/js/mappingXmlToInputFields.test.js
@@ -51,13 +51,14 @@ describe("mappingXmlToInputFields helpers", () => {
`;
const ctx = loadMappingModule();
+ const nsResolver = (prefix) => prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
- const xml = `
- Genome Sequencing Data
- `;
+ const xml = `
+ Genome Sequencing Data
+ `;
const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
- ctx.processResourceType(xmlDoc);
+ ctx.processResourceType(xmlDoc, nsResolver);
const select = document.getElementById("input-resourceinformation-resourcetype");
expect(select.value).toBe("Dataset");
});
@@ -216,30 +217,31 @@ describe("mappingXmlToInputFields helpers", () => {
test("getGeoLocationData extracts boxes and points correctly", () => {
const ctx = loadMappingModule();
+ const nsResolver = (prefix) => prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
const xml =
- `\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` -123.27\n` +
- ` -123.02\n` +
- ` 49.195\n` +
- ` 49.315\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` 41.2827\n` +
- ` -101.1207\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ``;
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` -123.27\n` +
+ ` -123.02\n` +
+ ` 49.195\n` +
+ ` 49.315\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` 41.2827\n` +
+ ` -101.1207\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ``;
const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
- const nodes = xmlDoc.querySelectorAll("geoLocation");
- const first = ctx.getGeoLocationData(nodes[0]);
- const second = ctx.getGeoLocationData(nodes[1]);
+ const nodes = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation", xmlDoc, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ const first = ctx.getGeoLocationData(nodes.snapshotItem(0), xmlDoc, nsResolver);
+ const second = ctx.getGeoLocationData(nodes.snapshotItem(1), xmlDoc, nsResolver);
expect(first).toEqual({
place: "",
@@ -260,21 +262,22 @@ describe("mappingXmlToInputFields helpers", () => {
test("getGeoLocationData handles a single point", () => {
const ctx = loadMappingModule();
+ const nsResolver = (prefix) => prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
const xml =
- `\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` 12.34\n` +
- ` 56.78\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ``;
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` 12.34\n` +
+ ` 56.78\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ``;
const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
- const node = xmlDoc.querySelector("geoLocation");
- const data = ctx.getGeoLocationData(node);
+ const node = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation", xmlDoc, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
+ const data = ctx.getGeoLocationData(node, xmlDoc, nsResolver);
expect(data).toEqual({
place: "",
@@ -287,23 +290,24 @@ describe("mappingXmlToInputFields helpers", () => {
test("getGeoLocationData handles a single box", () => {
const ctx = loadMappingModule();
+ const nsResolver = (prefix) => prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
const xml =
- `\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` -10\n` +
- ` 10\n` +
- ` -20\n` +
- ` 20\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ``;
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` -10\n` +
+ ` 10\n` +
+ ` -20\n` +
+ ` 20\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ``;
const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
- const node = xmlDoc.querySelector("geoLocation");
- const data = ctx.getGeoLocationData(node);
+ const node = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation", xmlDoc, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
+ const data = ctx.getGeoLocationData(node, xmlDoc, nsResolver);
expect(data).toEqual({
place: "",
@@ -316,30 +320,31 @@ describe("mappingXmlToInputFields helpers", () => {
test("getGeoLocationData handles point then box order", () => {
const ctx = loadMappingModule();
+ const nsResolver = (prefix) => prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
const xml =
- `\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` 1\n` +
- ` 2\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` -5\n` +
- ` 5\n` +
- ` -6\n` +
- ` 6\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ``;
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` 1\n` +
+ ` 2\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` -5\n` +
+ ` 5\n` +
+ ` -6\n` +
+ ` 6\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ``;
const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
- const nodes = xmlDoc.querySelectorAll("geoLocation");
- const first = ctx.getGeoLocationData(nodes[0]);
- const second = ctx.getGeoLocationData(nodes[1]);
+ const nodes = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation", xmlDoc, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ const first = ctx.getGeoLocationData(nodes.snapshotItem(0), xmlDoc, nsResolver);
+ const second = ctx.getGeoLocationData(nodes.snapshotItem(1), xmlDoc, nsResolver);
expect(first).toEqual({
place: "",
@@ -360,32 +365,33 @@ describe("mappingXmlToInputFields helpers", () => {
test("getGeoLocationData includes geoLocationPlace values", () => {
const ctx = loadMappingModule();
+ const nsResolver = (prefix) => prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
const xml =
- `\n` +
- ` \n` +
- ` \n` +
- ` Pacific Ocean\n` +
- ` \n` +
- ` -33\n` +
- ` 151\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ` Area 51\n` +
- ` \n` +
- ` -115.9\n` +
- ` -115.7\n` +
- ` 37.2\n` +
- ` 37.3\n` +
- ` \n` +
- ` \n` +
- ` \n` +
- ``;
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ` Pacific Ocean\n` +
+ ` \n` +
+ ` -33\n` +
+ ` 151\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ` Area 51\n` +
+ ` \n` +
+ ` -115.9\n` +
+ ` -115.7\n` +
+ ` 37.2\n` +
+ ` 37.3\n` +
+ ` \n` +
+ ` \n` +
+ ` \n` +
+ ``;
const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
- const nodes = xmlDoc.querySelectorAll("geoLocation");
- const first = ctx.getGeoLocationData(nodes[0]);
- const second = ctx.getGeoLocationData(nodes[1]);
+ const nodes = xmlDoc.evaluate(".//ns:geoLocations/ns:geoLocation", xmlDoc, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
+ const first = ctx.getGeoLocationData(nodes.snapshotItem(0), xmlDoc, nsResolver);
+ const second = ctx.getGeoLocationData(nodes.snapshotItem(1), xmlDoc, nsResolver);
expect(first).toEqual({
place: "Pacific Ocean",
diff --git a/tests/js/xmlRoundtripDataLoss.test.js b/tests/js/xmlRoundtripDataLoss.test.js
new file mode 100644
index 000000000..7054c78c3
--- /dev/null
+++ b/tests/js/xmlRoundtripDataLoss.test.js
@@ -0,0 +1,1182 @@
+/**
+ * @file Round-trip data loss regression tests for XML export → import.
+ *
+ * These tests verify that data exported as XML by ELMO can be re-imported
+ * without information loss. Each test covers a previously identified data
+ * loss scenario and serves as a regression test.
+ *
+ * IMPORTANT: jsdom's XPath only works with explicit namespace prefixes (ns:element).
+ * The real XSLT export uses default namespace () without prefixes.
+ * Tests use ns: prefix XML for XPath compatibility.
+ * Additionally, jsdom XPath does NOT support attribute predicates on namespaced
+ * elements (e.g. ns:nameIdentifier[@scheme="ORCID"]), so some fields cannot be
+ * tested here; those are covered by Playwright E2E tests.
+ */
+
+const fs = require("fs");
+const path = require("path");
+const vm = require("vm");
+
+function loadMappingModule(contextOverrides = {}) {
+ const code = fs.readFileSync(
+ path.resolve(__dirname, "../../js/mappingXmlToInputFields.js"),
+ "utf8"
+ );
+ const context = {
+ console,
+ document: global.document,
+ window: global.window,
+ XPathResult: global.XPathResult,
+ ELMO_FEATURES: {},
+ Tagify: jest.fn().mockImplementation(() => ({
+ addTags: jest.fn(),
+ removeAllTags: jest.fn(),
+ settings: { whitelist: [] },
+ })),
+ translations: {},
+ ...contextOverrides,
+ };
+ vm.createContext(context);
+ vm.runInContext(code, context);
+ return context;
+}
+
+/**
+ * Enhanced jQuery mock supporting all methods used by mappingXmlToInputFields.js.
+ */
+function createJQuery() {
+ const $ = (sel) => {
+ if (typeof sel === "function") {
+ sel();
+ return;
+ }
+ let elements;
+ if (typeof sel === "string") {
+ const firstMatch = sel.match(/^(.+):first$/);
+ if (firstMatch) {
+ const el = document.querySelector(firstMatch[1]);
+ elements = el ? [el] : [];
+ } else {
+ elements = Array.from(document.querySelectorAll(sel));
+ }
+ } else if (sel instanceof NodeList || Array.isArray(sel)) {
+ elements = Array.from(sel);
+ } else if (sel && sel.nodeType) {
+ elements = [sel];
+ } else {
+ elements = [];
+ }
+ return wrapElements(elements);
+ };
+
+ function wrapElements(elements) {
+ const obj = {
+ length: elements.length,
+ get: (i) => elements[i],
+ toArray: () => elements,
+ each(fn) {
+ elements.forEach((el, i) => fn.call(el, i, el));
+ return obj;
+ },
+ find(s) {
+ const results = [];
+ // Handle jQuery :first pseudo-selector (not valid CSS)
+ const firstMatch = s.match(/^(.+):first\s+(.+)$/);
+ if (firstMatch) {
+ elements.forEach((el) => {
+ const parents = el.querySelectorAll(firstMatch[1]);
+ if (parents.length > 0) {
+ results.push(...parents[0].querySelectorAll(firstMatch[2]));
+ }
+ });
+ } else if (s.includes(':first')) {
+ const cleanSel = s.replace(/:first$/, '');
+ elements.forEach((el) => {
+ const found = el.querySelectorAll(cleanSel);
+ if (found.length > 0) results.push(found[0]);
+ });
+ } else {
+ elements.forEach((el) => results.push(...el.querySelectorAll(s)));
+ }
+ return wrapElements(results);
+ },
+ filter(fnOrSel) {
+ if (typeof fnOrSel === "function") {
+ const filtered = elements.filter((el, i) => fnOrSel.call(el, i, el));
+ return wrapElements(filtered);
+ }
+ const filtered = elements.filter((el) => el.matches(fnOrSel));
+ return wrapElements(filtered);
+ },
+ first() {
+ return wrapElements(elements.length ? [elements[0]] : []);
+ },
+ last() {
+ return wrapElements(elements.length ? [elements[elements.length - 1]] : []);
+ },
+ eq(i) {
+ return wrapElements(i >= 0 && i < elements.length ? [elements[i]] : []);
+ },
+ closest(s) {
+ if (!elements.length) return wrapElements([]);
+ const found = elements[0].closest(s);
+ return found ? wrapElements([found]) : wrapElements([]);
+ },
+ val(v) {
+ if (v === undefined) {
+ return elements.length ? elements[0].value : undefined;
+ }
+ elements.forEach((el) => (el.value = v));
+ return obj;
+ },
+ text(v) {
+ if (v === undefined) {
+ return elements.length ? elements[0].textContent : "";
+ }
+ elements.forEach((el) => (el.textContent = v));
+ return obj;
+ },
+ prop(name, value) {
+ if (value === undefined) {
+ return elements.length ? elements[0][name] : undefined;
+ }
+ elements.forEach((el) => (el[name] = value));
+ return obj;
+ },
+ attr(name, value) {
+ if (value === undefined) {
+ return elements.length ? elements[0].getAttribute(name) : undefined;
+ }
+ elements.forEach((el) => el.setAttribute(name, value));
+ return obj;
+ },
+ addClass(cls) {
+ elements.forEach((el) => el.classList.add(cls));
+ return obj;
+ },
+ show() {
+ elements.forEach((el) => (el.style.display = ""));
+ return obj;
+ },
+ hide() {
+ elements.forEach((el) => (el.style.display = "none"));
+ return obj;
+ },
+ click() {
+ elements.forEach((el) => el.click());
+ return obj;
+ },
+ trigger: jest.fn().mockReturnThis(),
+ };
+ elements.forEach((el, i) => (obj[i] = el));
+ return obj;
+ }
+
+ $.getJSON = jest.fn();
+ return $;
+}
+
+/**
+ * Helper: build a DataCite XML string with explicit ns: prefix.
+ * This is needed because jsdom's XPath only works with explicit prefixes.
+ */
+function buildNsPrefixXml(content) {
+ return `${content}`;
+}
+
+const NS_RESOLVER = (prefix) =>
+ prefix === "ns" ? "http://datacite.org/schema/kernel-4" : null;
+
+// ─── FIX 1: funderIdentifierType now uses XPath instead of querySelector ────
+
+describe("funderIdentifierType import via XPath (regression for querySelector bug)", () => {
+ test("funderIdentifierType is correctly imported from namespaced XML", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ Deutsche Forschungsgemeinschaft
+ https://doi.org/10.13039/501100001659
+ DFG-12345
+ Seismic Monitoring
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processFunders(xmlDoc, NS_RESOLVER);
+
+ expect(document.querySelector('input[name="funder[]"]').value).toBe(
+ "Deutsche Forschungsgemeinschaft"
+ );
+ expect(document.querySelector('input[name="funderId[]"]').value).toBe(
+ "https://doi.org/10.13039/501100001659"
+ );
+ expect(document.querySelector('input[name="grantNummer[]"]').value).toBe("DFG-12345");
+ expect(document.querySelector('input[name="awardURI[]"]').value).toBe(
+ "https://gepris.dfg.de/12345"
+ );
+
+ // FIXED: funderIdentifierType is now correctly extracted via XPath
+ expect(document.querySelector('input[name="funderidtyp[]"]').value).toBe(
+ "Crossref Funder ID"
+ );
+ });
+
+ test("funderIdentifierType with ROR type is also correctly imported", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ European Research Council
+ https://ror.org/0472cxd90
+ ERC-123
+ Climate Research
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processFunders(xmlDoc, NS_RESOLVER);
+
+ expect(document.querySelector('input[name="funderidtyp[]"]').value).toBe("ROR");
+ expect(document.querySelector('input[name="funderId[]"]').value).toBe(
+ "https://ror.org/0472cxd90"
+ );
+ });
+});
+
+// ─── awardURI import verification ───────────────────────────────────────────
+
+describe("awardURI import from namespaced XML", () => {
+ test("awardURI is correctly imported via XPath getAttribute", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ DFG
+ 501100001659
+ DFG-12345
+ Seismic Monitoring
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processFunders(xmlDoc, NS_RESOLVER);
+
+ expect(document.querySelector('input[name="awardURI[]"]').value).toBe(
+ "https://gepris.dfg.de/12345"
+ );
+ });
+
+ test("processFunders also works for non-namespaced XML", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = `
+
+
+ NSF
+ https://doi.org/10.13039/100000001
+ NSF-123
+ Climate Research
+
+
+ `;
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processFunders(xmlDoc, NS_RESOLVER);
+
+ expect(document.querySelector('input[name="funder[]"]').value).toBe("NSF");
+ expect(document.querySelector('input[name="funderId[]"]').value).toBe(
+ "https://doi.org/10.13039/100000001"
+ );
+ expect(document.querySelector('input[name="funderidtyp[]"]').value).toBe(
+ "Crossref Funder ID"
+ );
+ expect(document.querySelector('input[name="grantNummer[]"]').value).toBe("NSF-123");
+ expect(document.querySelector('input[name="grantName[]"]').value).toBe("Climate Research");
+ expect(document.querySelector('input[name="awardURI[]"]').value).toBe(
+ "https://nsf.gov/award/123"
+ );
+ });
+});
+
+// ─── Contact person email/website from different XML sources ────────────────
+
+describe("Contact person email/website from DataCite-only XML", () => {
+ test("contact person checkbox is set via DataCite fallback (but email/website lost)", () => {
+ document.body.innerHTML = `
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ // DataCite-only XML (no ISO section) with ns: prefix
+ const xml = buildNsPrefixXml(`
+
+
+ Schmidt, Thomas
+ Thomas
+ Schmidt
+
+
+
+
+ Schmidt, Thomas
+ Thomas
+ Schmidt
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processContactPersons(xmlDoc);
+
+ // Contact person checkbox should be checked (DataCite fallback matches by name)
+ const checkbox = document.querySelector('input[name="contacts[]"]');
+ expect(checkbox.checked).toBe(true);
+
+ // Email and website are NOT available in DataCite schema
+ const emailField = document.querySelector('input[name="cpEmail[]"]');
+ const websiteField = document.querySelector('input[name="cpOnlineResource[]"]');
+ expect(emailField.value).toBe("");
+ expect(websiteField.value).toBe("");
+ });
+
+ test("contact person email/website preserved when ISO section is present", () => {
+ document.body.innerHTML = `
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ // Envelope with DataCite (ns: prefix) + ISO section
+ const xml = `
+
+
+
+ Schmidt, Thomas
+ Thomas
+ Schmidt
+
+
+
+
+ Schmidt, Thomas
+ Thomas
+ Schmidt
+
+
+
+
+
+
+
+ Schmidt, Thomas
+
+
+
+
+
+
+ Schmidt, Thomas
+
+
+
+
+
+
+ thomas@gfz.de
+
+
+
+
+
+
+ https://gfz.de/thomas
+
+
+
+
+
+
+
+
+`;
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processContactPersons(xmlDoc);
+
+ const checkbox = document.querySelector('input[name="contacts[]"]');
+ expect(checkbox.checked).toBe(true);
+
+ const emailField = document.querySelector('input[name="cpEmail[]"]');
+ const websiteField = document.querySelector('input[name="cpOnlineResource[]"]');
+ expect(emailField.value).toBe("thomas@gfz.de");
+ expect(websiteField.value).toBe("https://gfz.de/thomas");
+ });
+});
+
+// ─── FIX 3: Contact person no longer dropped when not matching any author ───
+
+describe("Contact person added as new author when name doesn't match (regression)", () => {
+ test("contact person with different name than authors is added as new author row", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ // Simulate add-author button creating a new row
+ document.getElementById("button-author-add").addEventListener("click", () => {
+ const container = document.getElementById("group-author");
+ const firstRow = container.querySelector("[data-creator-row]");
+ const clone = firstRow.cloneNode(true);
+ clone.querySelectorAll("input").forEach((i) => {
+ if (i.type === "checkbox") i.checked = false;
+ else i.value = "";
+ });
+ clone.querySelector(".contact-person-input").style.display = "none";
+ container.appendChild(clone);
+ });
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ // Contact person "Müller" is not one of the authors ("Schmidt")
+ const xml = buildNsPrefixXml(`
+
+
+ Müller, Erika
+ Erika
+ Müller
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processContactPersons(xmlDoc);
+
+ // FIXED: A new author row is created for the contact person
+ const allRows = document.querySelectorAll("[data-creator-row]");
+ expect(allRows.length).toBe(2);
+
+ // Original author unchanged
+ expect(allRows[0].querySelector('input[name="familynames[]"]').value).toBe("Schmidt");
+ expect(allRows[0].querySelector('input[name="contacts[]"]').checked).toBe(false);
+
+ // New row has contact person data and is marked as contact
+ expect(allRows[1].querySelector('input[name="familynames[]"]').value).toBe("Müller");
+ expect(allRows[1].querySelector('input[name="givennames[]"]').value).toBe("Erika");
+ expect(allRows[1].querySelector('input[name="contacts[]"]').checked).toBe(true);
+ });
+});
+
+// ─── Relation type matching works with CamelCase ────────────────────────────
+
+describe("Relation type matching with CamelCase option text", () => {
+ test("relation type CamelCase from XML matches CamelCase dropdown option", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+ 10.5555/related
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processRelatedWorks(xmlDoc, NS_RESOLVER);
+
+ const idField = document.querySelector('input[name="rIdentifier[]"]');
+ expect(idField.value).toBe("10.5555/related");
+
+ // CamelCase option text "IsSupplementTo" matches CamelCase XML relationType
+ const relationSelect = document.querySelector('select[name="relation[]"]');
+ const selectedOption = relationSelect.querySelector("option[selected]") ||
+ Array.from(relationSelect.options).find((o) => o.selected && o.value !== "");
+ expect(selectedOption).toBeTruthy();
+ expect(selectedOption.value).toBe("3");
+ });
+});
+
+// ─── BUG 6: Multiple funding references ─────────────────────────────────────
+
+describe("Multiple funding references import", () => {
+ test("second funding reference row is correctly populated", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ let addClicked = 0;
+ document.getElementById("button-fundingreference-add").addEventListener("click", () => {
+ addClicked++;
+ const container = document.getElementById("group-fundingreference");
+ const firstRow = container.querySelector(".row");
+ const clone = firstRow.cloneNode(true);
+ clone.querySelectorAll("input").forEach((i) => (i.value = ""));
+ container.appendChild(clone);
+ });
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ DFG
+ 501100001659
+ GRANT-1
+ First Grant
+
+
+ ERC
+ https://ror.org/0472cxd90
+ GRANT-2
+ Second Grant
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processFunders(xmlDoc, NS_RESOLVER);
+
+ expect(addClicked).toBe(1);
+
+ const rows = document.querySelectorAll("#group-fundingreference .row");
+ expect(rows.length).toBe(2);
+
+ expect(rows[0].querySelector('input[name="funder[]"]').value).toBe("DFG");
+ expect(rows[0].querySelector('input[name="grantNummer[]"]').value).toBe("GRANT-1");
+
+ expect(rows[1].querySelector('input[name="funder[]"]').value).toBe("ERC");
+ expect(rows[1].querySelector('input[name="grantNummer[]"]').value).toBe("GRANT-2");
+ });
+});
+
+// ─── BUG 7: Temporal coverage timezone parsing edge cases ───────────────────
+
+describe("Temporal coverage timezone offset parsing", () => {
+ test("negative timezone offset is correctly extracted", () => {
+ const ctx = loadMappingModule();
+
+ const xml = `2025-01-15T08:00:00-05:00/2025-06-30T17:00:00-05:00`;
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const dateNode = xmlDoc.querySelector("date");
+
+ const result = ctx.parseTemporalData(dateNode);
+ expect(result.startDate).toBe("2025-01-15");
+ expect(result.startTime).toBe("08:00:00");
+ expect(result.endDate).toBe("2025-06-30");
+ expect(result.endTime).toBe("17:00:00");
+ expect(result.timezoneOffset).toBe("-05:00");
+ });
+
+ test("date without time but with timezone parses correctly", () => {
+ const ctx = loadMappingModule();
+
+ const xml = `2025-01-15+02:00/2025-06-30+02:00`;
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const dateNode = xmlDoc.querySelector("date");
+
+ const result = ctx.parseTemporalData(dateNode);
+ expect(result.startDate).toBe("2025-01-15");
+ expect(result.startTime).toBe("");
+ expect(result.endDate).toBe("2025-06-30");
+ expect(result.endTime).toBe("");
+ expect(result.timezoneOffset).toBe("+02:00");
+ });
+
+ test("date with negative offset and no time extracts timezone correctly", () => {
+ const ctx = loadMappingModule();
+
+ const xml = `2025-03-01-08:00`;
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const dateNode = xmlDoc.querySelector("date");
+
+ const result = ctx.parseTemporalData(dateNode);
+ expect(result.startDate).toBe("2025-03-01");
+ expect(result.timezoneOffset).toBe("-08:00");
+ });
+});
+
+// ─── Known limitation: Description xml:lang not stored in form ──────────────
+
+describe("Description xml:lang attribute limitation", () => {
+ test("description content is imported correctly", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+ Abstrakt auf Deutsch
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processDescriptions(xmlDoc, NS_RESOLVER);
+
+ expect(document.getElementById("input-abstract").value).toBe("Abstrakt auf Deutsch");
+ });
+
+ // TODO(#1055): xml:lang for descriptions is not persisted in the form.
+ // On re-export, the language defaults to "en" instead of the original value.
+ // Add a form field for description language and persist it through the roundtrip.
+ test.todo("description xml:lang attribute should be preserved during roundtrip");
+});
+
+// ─── Known limitation: Title xml:lang not stored in form ────────────────────
+
+describe("Title xml:lang attribute limitation", () => {
+ test("title content is imported correctly", () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+ Titre en français
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processTitles(xmlDoc, NS_RESOLVER, { "": "1", MainTitle: "1" });
+
+ expect(document.querySelector('input[name="title[]"]').value).toBe("Titre en français");
+ });
+
+ // TODO(#1055): xml:lang for titles is not persisted in the form.
+ // On re-export, the language defaults to "en" instead of the original value.
+ // Add a form field for title language and persist it through the roundtrip.
+ test.todo("title xml:lang attribute should be preserved during roundtrip");
+});
+
+// ─── BUG 10: Thesaurus keyword metadata round-trip ──────────────────────────
+
+describe("Keyword metadata round-trip via Tagify tags", () => {
+ test("thesaurus keyword schemeURI and valueURI are preserved in Tagify tag data", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const mockScienceTagify = {
+ addTags: jest.fn(),
+ removeAllTags: jest.fn(),
+ };
+ const mockFreeTagify = {
+ addTags: jest.fn(),
+ removeAllTags: jest.fn(),
+ };
+ document.getElementById("input-freekeyword")._tagify = mockFreeTagify;
+ document.getElementById("input-sciencekeyword")._tagify = mockScienceTagify;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+ EARTH SCIENCE > SOLID EARTH
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processKeywords(xmlDoc, NS_RESOLVER);
+
+ expect(mockScienceTagify.addTags).toHaveBeenCalled();
+
+ const addedTag = mockScienceTagify.addTags.mock.calls[0][0][0];
+ expect(addedTag.value).toBe("EARTH SCIENCE > SOLID EARTH");
+ expect(addedTag.schemeURI).toBe(
+ "https://gcmd.earthdata.nasa.gov/kms/concepts/concept_scheme/sciencekeywords"
+ );
+ expect(addedTag.id).toBe("https://gcmd.earthdata.nasa.gov/kms/concept/123");
+ expect(addedTag.scheme).toBe("NASA/GCMD Earth Science Keywords");
+ expect(addedTag.language).toBe("en");
+ });
+});
+
+// ─── Author ORCID URL stripping ─────────────────────────────────────
+
+describe("Author ORCID URL stripping", () => {
+ test("creator name and affiliation are imported from namespaced XML", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ Schmidt, Thomas
+ Thomas
+ Schmidt
+ https://orcid.org/0000-0001-2345-6789
+ GFZ Potsdam
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processCreators(xmlDoc, NS_RESOLVER);
+
+ // Name fields work (simple XPath without attribute predicates)
+ expect(document.querySelector('input[name="familynames[]"]').value).toBe("Schmidt");
+ expect(document.querySelector('input[name="givennames[]"]').value).toBe("Thomas");
+
+ // ORCID extraction depends on XPath attribute predicate support.
+ // jsdom currently returns empty; real browsers return the stripped ORCID.
+ const orcidValue = document.querySelector('input[name="orcids[]"]').value;
+ if (orcidValue !== "") {
+ // If the engine supports attribute predicates, verify URL prefix is stripped
+ expect(orcidValue).toBe("0000-0001-2345-6789");
+ }
+ });
+});
+
+// ─── Contributor ORCID handling ─────────────────────────────────────
+
+describe("Contributor ORCID extraction", () => {
+ test("contributor name and role are extracted from namespaced XML", () => {
+ const ctx = loadMappingModule();
+
+ const xml = buildNsPrefixXml(`
+
+
+ Müller, Erika
+ Erika
+ Müller
+ 0000-0002-9876-5432
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+
+ const personMap = new Map();
+ const orgMap = new Map();
+ const contributorNode = xmlDoc.evaluate(
+ ".//ns:contributors/ns:contributor",
+ xmlDoc,
+ NS_RESOLVER,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ).singleNodeValue;
+
+ expect(contributorNode).not.toBeNull();
+ ctx.processIndividualContributor(contributorNode, xmlDoc, NS_RESOLVER, personMap, orgMap);
+
+ expect(personMap.size).toBe(1);
+ const person = personMap.values().next().value;
+ expect(person.givenName).toBe("Erika");
+ expect(person.familyName).toBe("Müller");
+ expect(person.roles).toContain("Data Collector");
+
+ // ORCID extraction depends on XPath attribute predicate support.
+ // jsdom currently returns empty; real browsers return the ORCID.
+ if (person.orcid !== "") {
+ expect(person.orcid).toBe("0000-0002-9876-5432");
+ }
+ });
+});
+
+// ─── FIX 3: resourceType now uses XPath instead of querySelector ────────────
+
+describe("resourceType import via XPath (regression for querySelector bug)", () => {
+ test("processResourceType correctly reads resourceTypeGeneral from namespaced XML", () => {
+ document.body.innerHTML = `
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+ Analysis Code`);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processResourceType(xmlDoc, NS_RESOLVER);
+
+ const select = document.getElementById("input-resourceinformation-resourcetype");
+ expect(select.value).toBe("2");
+ });
+
+ test("processResourceType handles Dataset type correctly", () => {
+ document.body.innerHTML = `
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+ Research Data`);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processResourceType(xmlDoc, NS_RESOLVER);
+
+ const select = document.getElementById("input-resourceinformation-resourcetype");
+ expect(select.value).toBe("1");
+ });
+
+ test("processResourceType also works for non-namespaced XML", () => {
+ document.body.innerHTML = `
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = `Analysis Code`;
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processResourceType(xmlDoc, NS_RESOLVER);
+
+ const select = document.getElementById("input-resourceinformation-resourcetype");
+ expect(select.value).toBe("2");
+ });
+});
+
+// ─── FIX 4: geoLocation data now uses XPath instead of querySelector ────────
+
+describe("geoLocation import via XPath (regression for querySelector bug)", () => {
+ test("getGeoLocationData extracts bounding box coordinates from namespaced XML", () => {
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ Berlin, Germany
+
+ 13.0
+ 13.8
+ 52.3
+ 52.7
+
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const geoNode = xmlDoc.evaluate(
+ ".//ns:geoLocations/ns:geoLocation",
+ xmlDoc,
+ NS_RESOLVER,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ).singleNodeValue;
+
+ expect(geoNode).not.toBeNull();
+ const data = ctx.getGeoLocationData(geoNode, xmlDoc, NS_RESOLVER);
+
+ expect(data.place).toBe("Berlin, Germany");
+ expect(data.latitudeMin).toBe("52.3");
+ expect(data.latitudeMax).toBe("52.7");
+ expect(data.longitudeMin).toBe("13.0");
+ expect(data.longitudeMax).toBe("13.8");
+ });
+
+ test("getGeoLocationData extracts point coordinates from namespaced XML", () => {
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ Potsdam
+
+ 52.3906
+ 13.0645
+
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const geoNode = xmlDoc.evaluate(
+ ".//ns:geoLocations/ns:geoLocation",
+ xmlDoc,
+ NS_RESOLVER,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ).singleNodeValue;
+
+ const data = ctx.getGeoLocationData(geoNode, xmlDoc, NS_RESOLVER);
+
+ expect(data.place).toBe("Potsdam");
+ // Point coordinates should be set for both min and max
+ expect(data.latitudeMin).toBe("52.3906");
+ expect(data.latitudeMax).toBe("52.3906");
+ expect(data.longitudeMin).toBe("13.0645");
+ expect(data.longitudeMax).toBe("13.0645");
+ });
+
+ test("getGeoLocationData returns empty strings when no spatial data present", () => {
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = buildNsPrefixXml(`
+
+
+ Somewhere
+
+ `);
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const geoNode = xmlDoc.evaluate(
+ ".//ns:geoLocations/ns:geoLocation",
+ xmlDoc,
+ NS_RESOLVER,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ).singleNodeValue;
+
+ const data = ctx.getGeoLocationData(geoNode, xmlDoc, NS_RESOLVER);
+
+ expect(data.place).toBe("Somewhere");
+ expect(data.latitudeMin).toBe("");
+ expect(data.latitudeMax).toBe("");
+ expect(data.longitudeMin).toBe("");
+ expect(data.longitudeMax).toBe("");
+ });
+
+ test("getGeoLocationData also works for non-namespaced XML", () => {
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = `
+
+
+ Munich
+
+ 11.3
+ 11.8
+ 48.0
+ 48.3
+
+
+
+ `;
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ const geoNode = xmlDoc.evaluate(
+ ".//geoLocations/geoLocation",
+ xmlDoc,
+ NS_RESOLVER,
+ XPathResult.FIRST_ORDERED_NODE_TYPE,
+ null
+ ).singleNodeValue;
+
+ expect(geoNode).not.toBeNull();
+ const data = ctx.getGeoLocationData(geoNode, xmlDoc, NS_RESOLVER);
+
+ expect(data.place).toBe("Munich");
+ expect(data.latitudeMin).toBe("48.0");
+ expect(data.latitudeMax).toBe("48.3");
+ expect(data.longitudeMin).toBe("11.3");
+ expect(data.longitudeMax).toBe("11.8");
+ });
+});
+
+// ─── processSpatialTemporalCoverages: non-namespaced temporal coverage ──────
+
+describe("processSpatialTemporalCoverages namespace fallback", () => {
+ test("spatial and temporal coverage found in non-namespaced XML", () => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const $ = createJQuery();
+ const ctx = loadMappingModule({ $ });
+
+ const xml = `
+
+
+ Berlin
+
+ 13.0
+ 13.8
+ 52.3
+ 52.7
+
+
+
+
+ 2024-01-15/2024-06-30
+
+ `;
+
+ const xmlDoc = new DOMParser().parseFromString(xml, "application/xml");
+ ctx.processSpatialTemporalCoverages(xmlDoc, NS_RESOLVER);
+
+ // Spatial fields populated via non-namespaced geoLocation fallback
+ expect(document.querySelector('textarea[name="tscDescription[]"]').value).toBe("Berlin");
+ expect(document.querySelector('input[name="tscLatitudeMin[]"]').value).toBe("52.3");
+ expect(document.querySelector('input[name="tscLongitudeMin[]"]').value).toBe("13.0");
+
+ // NOTE: jsdom does not support XPath attribute predicates ([@dateType="..."])
+ // on XML elements, so temporal fields remain empty in this test.
+ // The dateNodes XPath fallback is structurally identical to the geoLocationNodes
+ // fallback (which is verified above) and works correctly in real browsers.
+ });
+});