From 0e0c05ef2dc71aa71383fa1cf1e64b08ad353bf2 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 19:39:53 +0000 Subject: [PATCH 1/6] Specs: lazy loads checked by direction and object; the attribute as sent From the review of 20 Oct (spec 006). The stand-ins for graph-core's lazy loads answered any query naming the relationship, so a load in the wrong direction, or of another object, passed; they now answer by type, direction and the dbId asked for, read from the query (directions as domain-snapshot.json gives Java's model). /query/{id}/stId and /normalPathway are held to naming the attribute as the request spelled it. Each shown failing under the mutation it exists to catch. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/contained-events.spec.mjs | 36 +++++++----- .../query-attribute-endpoint.spec.mjs | 56 +++++++++++++++++-- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/tools/content-node/contained-events.spec.mjs b/tools/content-node/contained-events.spec.mjs index dd014ff5..60acfe3c 100644 --- a/tools/content-node/contained-events.spec.mjs +++ b/tools/content-node/contained-events.spec.mjs @@ -44,8 +44,14 @@ vi.mock('./graph.ts', () => ({ asked.push([cypher, params]); if (cypher.includes('[r:hasEvent*1..100]')) return params.stId === 'R-HSA-100' || params.dbId ? (records ?? RECORDS) : []; - for (const [type, rows] of Object.entries(lazy)) - if (cypher.includes(`[r:${type}]`)) return rows; + // A lazy load by what it asks: `${type} ${direction} ${dbId}`, so one in + // the wrong direction, or of another event, finds nothing. + const load = /\{dbId:\$dbId\}\)(<-|-)\[r:(\w+)\](->|-)/.exec(cypher); + if (load) + return ( + lazy[`${load[2]} ${load[1] === '<-' ? 'INCOMING' : 'OUTGOING'} ${Number(params.dbId)}`] ?? + [] + ); return []; }, configured: () => true, @@ -113,22 +119,24 @@ describe('/data/pathway/{id}/containedEvents/{attributeName}', () => { expect(await response.text()).toBe('[R-HSA-200, R-HSA-300]'); }); - it('loads what findAll did not bring, outgoing relationships too', async () => { - lazy = { - compartment: [ - { - m: { - id: 9, - labels: ['DatabaseObject', 'Compartment'], - props: { dbId: 70101, displayName: 'cytosol', schemaClass: 'Compartment' }, - }, - n: null, + it("loads what findAll did not bring, outgoing relationships too, each event's own", async () => { + const compartment = (id, dbId, displayName) => [ + { + m: { + id, + labels: ['DatabaseObject', 'Compartment'], + props: { dbId, displayName, schemaClass: 'Compartment' }, }, - ], + n: null, + }, + ]; + lazy = { + 'compartment OUTGOING 200': compartment(9, 70101, 'cytosol'), + 'compartment OUTGOING 300': compartment(10, 7660, 'nucleoplasm'), }; const response = await get('R-HSA-100/containedEvents/compartment'); expect(await response.text()).toBe( - '[70101\tcytosol\tCompartment\n, 70101\tcytosol\tCompartment\n]' + '[70101\tcytosol\tCompartment\n, 7660\tnucleoplasm\tCompartment\n]' ); lazy = {}; }); diff --git a/tools/content-node/query-attribute-endpoint.spec.mjs b/tools/content-node/query-attribute-endpoint.spec.mjs index df1263f8..cacac83b 100644 --- a/tools/content-node/query-attribute-endpoint.spec.mjs +++ b/tools/content-node/query-attribute-endpoint.spec.mjs @@ -9,14 +9,29 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; let root; let lazy = {}; const asked = []; +// The lazy loads by what they ask: `${type} ${direction} ${dbId}`, read from +// the query and its parameter -- a load in the wrong direction, or of +// another object, finds nothing. Directions are Java's model's +// (domain-snapshot.json): an edit's `modified` comes in to the object, and +// so does the `hasComponent` behind a complex's componentOf. vi.mock('./graph.ts', () => ({ - read: async (cypher) => { + read: async (cypher, params) => { asked.push(cypher); + // /query/{id}/stId's and /query/{id}/normalPathway's own queries. + if (/n\.stId AS stId, n\.schemaClass AS schemaClass/.test(cypher)) + return root ? [{ stId: root.props.stId ?? null, schemaClass: root.props.schemaClass }] : []; + if (/\[:normalPathway\]/.test(cypher)) + return root + ? [{ labels: root.labels, dbId: null, stId: null, displayName: null, targetLabels: null }] + : []; if (/RETURN labels\(n\) AS labels/.test(cypher)) return root ? [{ labels: root.labels }] : []; if (/AS root/.test(cypher)) return root ? [{ root, order: [], nodes: [], relationships: [] }] : []; - for (const [type, rows] of Object.entries(lazy)) - if (cypher.includes(`[r:${type}]`)) return rows; + const load = /\{dbId:\$dbId\}\)(<-|-)\[r:(\w+)\](->|-)/.exec(cypher); + if (load) { + const direction = load[1] === '<-' ? 'INCOMING' : 'OUTGOING'; + return lazy[`${load[2]} ${direction} ${Number(params.dbId)}`] ?? []; + } return []; }, configured: () => true, @@ -55,7 +70,7 @@ const loaded = (id, schemaClass, labels, n = null) => ({ describe('a single object loaded lazily', () => { it("is the one there is, asked for by the getter's class", async () => { root = pathway; - lazy = { modified: [loaded(20, 'InstanceEdit', ['InstanceEdit'])] }; + lazy = { 'modified INCOMING 9': [loaded(20, 'InstanceEdit', ['InstanceEdit'])] }; asked.length = 0; const response = await get('query/R-HSA-9/modified'); expect([response.status, await response.text()]).toEqual([200, 'R-HSA-20\to20\tInstanceEdit']); @@ -65,7 +80,7 @@ describe('a single object loaded lazily', () => { it("is nothing when there are two, as findByRelationship gives: Java's 404", async () => { root = pathway; lazy = { - modified: [ + 'modified INCOMING 9': [ loaded(20, 'InstanceEdit', ['InstanceEdit']), loaded(21, 'InstanceEdit', ['InstanceEdit']), ], @@ -84,12 +99,41 @@ describe('a collection loaded lazily', () => { labels: ['DatabaseObject', 'PhysicalEntity', 'Complex'], props: { dbId: 5, stId: 'R-HSA-5', displayName: 'C', schemaClass: 'Complex' }, }; - lazy = { hasComponent: [loaded(30, 'Complex', ['PhysicalEntity', 'Complex'], 2)] }; + // The complexes it is a component of: hasComponent, coming in. + lazy = { + 'hasComponent INCOMING 5': [loaded(30, 'Complex', ['PhysicalEntity', 'Complex'], 2)], + }; const response = await get('query/R-HSA-5/componentOf'); expect(await response.text()).toBe('R-HSA-30\to30\tComplex\nR-HSA-30\to30\tComplex\n'); }); }); +describe('the attribute as it was sent', () => { + // Routing matches stId and normalPathway in any case; Java's 404 names + // the attribute as the request spelled it. + it("is named in /query/{id}/stId's 404 for an object without a stable id", async () => { + root = { + id: 6, + labels: ['DatabaseObject', 'Taxon', 'Species'], + props: { dbId: 48887, displayName: 'Homo sapiens', schemaClass: 'Species' }, + }; + for (const spelled of ['stId', 'STID', 'StId']) + expect(await messages(await get(`query/48887/${spelled}`))).toEqual([ + 404, + [`Attribute: '${spelled}' has not been found for object: Species`], + ]); + }); + + it("is named in /query/{id}/normalPathway's 404 for an object without one", async () => { + root = pathway; + for (const spelled of ['normalPathway', 'NormalPathway', 'NORMALPATHWAY']) + expect(await messages(await get(`query/R-HSA-9/${spelled}`))).toEqual([ + 404, + [`Attribute: '${spelled}' has not been found for object: Pathway`], + ]); + }); +}); + describe("Java's other answers", () => { it("leaves Java's own /query/abridged/{id}, /query/enhanced/{id} and /query/{id}/more alone", async () => { root = pathway; From 096e7a9430e18f796a367a71e0f25b9299f466e6 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 19:47:08 +0000 Subject: [PATCH 2/6] Specs: answers found, Java's messages, and queries held exactly From the review of 20 Oct (spec 006). Each shown failing under the mutation it exists to catch. - /query/ids and /ids/map: a test where objects are found -- /ids in the HashSet order, /ids/map keyed by the ids as sent in the HashMap order (read from the text: JSON.parse moves a numeric key first). Writing either in the order sent now fails. - Nine of Java's 500s on the Solr paths were checked by status alone; each now names Java's NullPointerException answer, so one answered as a plain Solr failure fails. - Stand-ins that answered any query now answer only the query the service writes: the diagram-search object check (hasDiagram), the species by exact name, the release (releaseNumber), discover's load (whole text). - /citation/pathway/{id} cited end to end: a pathway by its DOI, a reaction by its page, both by the id as given; /citation/export's three formats each with their type and extension. - /references/mapping/{identifier}'s query pattern is anchored at the end; /search/query's targetResults are held in Java's order, not merely present; the no-report check waits on a later report, not 50ms. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/citation.spec.ts | 71 ++++++++++++++++++- tools/content-node/diagram-search.spec.mjs | 11 ++- tools/content-node/discover.spec.mjs | 11 ++- tools/content-node/flag.spec.mjs | 17 ++++- tools/content-node/query-ids.spec.mjs | 59 ++++++++++++++- tools/content-node/query-search.spec.mjs | 23 ++++-- .../content-node/references-mapping.spec.mjs | 2 +- tools/content-node/species-search.spec.mjs | 12 +++- 8 files changed, 191 insertions(+), 15 deletions(-) diff --git a/tools/content-node/citation.spec.ts b/tools/content-node/citation.spec.ts index 46d3e30c..639705ad 100644 --- a/tools/content-node/citation.spec.ts +++ b/tools/content-node/citation.spec.ts @@ -285,7 +285,9 @@ describe('StaticCitation', () => { vi.mock('./graph.ts', async () => ({ read: async (cypher, params) => { - if (cypher.includes('DBInfo')) return [{ version: 97 }]; + // The release as the service reads it: releaseNumber, not another property. + if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1') + return [{ version: 97 }]; globalThis.graphAsked = params; return globalThis.graphRows ?? []; }, @@ -358,6 +360,73 @@ describe('the citation endpoints', () => { ]); }); + // An event as graph-core's enhanced query loads it, with nothing related. + const eventRow = (schemaClass: string, labels: string[]) => ({ + root: { + id: 1, + labels: ['DatabaseObject', 'Event', ...labels], + props: { + dbId: 9, + stId: 'R-HSA-9', + displayName: 'An event', + releaseDate: '2020-06-15', + doi: '10.3180/R-HSA-9.1', + schemaClass, + }, + }, + order: [], + nodes: [], + relationships: [], + }); + + it("/pathway/{id} cites the event by the id as given; only a pathway's by its DOI", async () => { + try { + globalThis.graphRows = [eventRow('Pathway', ['Pathway'])]; + const pathway = await ( + await fetch(`${base}/ContentService/citation/pathway/9?dateAccessed=d`) + ).json(); + // Asked by its stable id: the link names the id as given, not its dbId. + globalThis.graphRows = [eventRow('Reaction', ['ReactionLikeEvent', 'Reaction'])]; + const reaction = await ( + await fetch(`${base}/ContentService/citation/pathway/R-HSA-9?dateAccessed=d`) + ).json(); + expect([pathway, reaction]).toEqual([ + { + imageCitation: + 'Image Citation for An event. Reactome, 97, https://doi.org/10.3180/R-HSA-9.1 (d)', + pathwayCitation: + 'The Reactome Consortium (2020). An event. Reactome, 97, https://doi.org/10.3180/R-HSA-9.1 (d)', + }, + { + imageCitation: + 'Image Citation for An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9 (d)', + pathwayCitation: + 'The Reactome Consortium (2020). An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9 (d)', + }, + ]); + } finally { + globalThis.graphRows = []; + } + }); + + it('export writes each format as its own type and extension', async () => { + for (const [ext, type, extension] of [ + ['bib', 'application/x-bibtex; charset=utf-8', 'bib'], + ['ris', 'application/x-research-info-systems; charset=utf-8', 'ris'], + ['txt', 'text/plain; charset=utf-8', 'txt'], + ]) { + const response = await fetch( + `${base}/ContentService/citation/export?isPathway=false&id=12345&ext=${ext}&dateAccessed=d` + ); + expect([ + ext, + response.status, + response.headers.get('content-type'), + response.headers.get('content-disposition'), + ]).toEqual([ext, 200, type, `attachment; filename="reactome_citation_12345.${extension}"`]); + } + }); + it('/static/{id} is "unable to fetch" for an answer Java cannot read', async () => { globalThis.pmcAnswer = { resultList: { result: [{ ...europePmc, journalInfo: [] }] } }; try { diff --git a/tools/content-node/diagram-search.spec.mjs b/tools/content-node/diagram-search.spec.mjs index 62f69506..747dc33e 100644 --- a/tools/content-node/diagram-search.spec.mjs +++ b/tools/content-node/diagram-search.spec.mjs @@ -15,7 +15,13 @@ const objects = { }; vi.mock('./graph.ts', () => ({ - read: async (_cypher, params) => (objects[params.id] ? [objects[params.id]] : []), + // Only the object check as the service writes it: a wrong property (a + // diagram flag read from hasEHLD, say) finds nothing. + read: async (cypher, params) => + cypher.includes('RETURN labels(n) AS labels, n.hasDiagram AS hasDiagram LIMIT 1') && + objects[params.id] + ? [objects[params.id]] + : [], configured: () => true, close: async () => {}, envSetting: (key, fallback = '') => process.env[key] || fallback, @@ -257,6 +263,9 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { answer = () => ({ response: { numFound: 1, docs: [{ occurrences: ['R-HSA-68886:true:x'] }] } }); const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); expect(short.status).toBe(500); + expect((await short.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it('asks again in every scope, then is the 404', async () => { diff --git a/tools/content-node/discover.spec.mjs b/tools/content-node/discover.spec.mjs index d7060667..d09e500c 100644 --- a/tools/content-node/discover.spec.mjs +++ b/tools/content-node/discover.spec.mjs @@ -209,7 +209,9 @@ const graph = if (/AS related/.test(cypher)) return [{ labels: (load[0] ?? LOADED).root.labels, related: load.length > 0 }]; if (/MATCH \(n\)-\[r\]-\(m\)/.test(cypher)) return load; - if (/DBInfo/.test(cypher)) return [{ version: 97 }]; + // The release as the service reads it: releaseNumber, not another property. + if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1') + return [{ version: 97 }]; // The edit's author, with no stoichiometry on the relationship: once. if (/<-\[r:author\]-/.test(cypher) && Number(params.dbId) === 8) return [{ m: { dbId: 20, surname: 'Roe' }, n: null }]; @@ -285,5 +287,12 @@ describe("/data/discover/{identifier}: Java's errors", () => { const load = asked.find((cypher) => /MATCH \(n\)-\[r\]-\(m\)/.test(cypher)); expect(load).toBeDefined(); expect(load).not.toMatch(/ORDER BY|OPTIONAL/); + // The object by the id asked, and the three columns mapResult reads. + expect(load?.replace(/\s+/g, ' ')).toBe( + 'MATCH (n:DatabaseObject) WHERE n.stId = $id WITH n LIMIT 1 MATCH (n)-[r]-(m) ' + + 'RETURN {id: id(n), labels: labels(n), props: properties(n)} AS root, ' + + 'COLLECT({id: id(r), type: type(r), start: id(startNode(r)), end: id(endNode(r)), props: properties(r)}) AS relationships, ' + + 'COLLECT({id: id(m), labels: labels(m), props: properties(m)}) AS nodes' + ); }); }); diff --git a/tools/content-node/flag.spec.mjs b/tools/content-node/flag.spec.mjs index 02090019..de7ce75a 100644 --- a/tools/content-node/flag.spec.mjs +++ b/tools/content-node/flag.spec.mjs @@ -20,8 +20,15 @@ const species = { vi.mock('./graph.ts', () => ({ read: async (cypher, params) => { - if (cypher.includes(':Species')) return [species[params.name] ?? []].flat(); - return objects[params.id] ? [objects[params.id]] : []; + // Only the queries as the service writes them: the species by exact + // name among its names, and the object check reading hasDiagram. + if (cypher.includes(':Species')) + return cypher.includes('MATCH (n:Species) WHERE $name IN n.name RETURN') + ? [species[params.name] ?? []].flat() + : []; + return cypher.includes('n.hasDiagram AS hasDiagram') && objects[params.id] + ? [objects[params.id]] + : []; }, configured: () => true, close: async () => {}, @@ -147,6 +154,9 @@ describe('/diagram/{pathwayId}/flag', () => { expect([bad.status, (await bad.json()).messages]).toEqual([400, ["'xyz' cannot be found"]]); const blank = await get('diagram/R-HSA-446203/flag?query=CTSA&includeInteractors=%20'); expect(blank.status).toBe(500); + expect((await blank.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); expect(asked).toHaveLength(0); }); }); @@ -218,6 +228,9 @@ describe('/fireworks/flag', () => { asked = []; const response = await get('fireworks/flag?query=x&species=Twice'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); expect(asked).toHaveLength(0); }); }); diff --git a/tools/content-node/query-ids.spec.mjs b/tools/content-node/query-ids.spec.mjs index 733bdc10..2fd7f460 100644 --- a/tools/content-node/query-ids.spec.mjs +++ b/tools/content-node/query-ids.spec.mjs @@ -1,15 +1,41 @@ // @vitest-environment node // // POST /data/query/ids and /data/query/ids/map -- a batch of objects by id -- -// with a stand-in graph that finds nothing, for Java's errors, and the two +// with a stand-in graph, for Java's errors and its answers, and the two // orders Java writes in: a HashSet of DatabaseObjects and a HashMap of ids. // The expected orders are the JDK's own (11, the version the service runs), // computed with graph-core's DatabaseObject.hashCode -- dbId, then stId, // then displayName, 31 apart. Java's answers measured 17 Oct 2026. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +// Three objects the stand-in graph finds, by the id each is asked by. +const FOUND = { + 'R-HSA-109581': [109581, 'Apoptosis'], + 'R-HSA-1640170': [1640170, 'Cell Cycle'], + 199420: [199420, 'PTEN [cytosol]'], +}; +let finds = false; vi.mock('./graph.ts', () => ({ - read: async () => [], + read: async (cypher, params) => { + const known = finds ? FOUND[params?.id] : undefined; + if (!known) return []; + const [dbId, displayName] = known; + const labels = ['DatabaseObject', 'Event', 'Pathway']; + if (/AS root/.test(cypher)) + return [ + { + root: { + id: dbId, + labels, + props: { dbId, stId: `R-HSA-${dbId}`, displayName, schemaClass: 'Pathway' }, + }, + order: [], + nodes: [], + relationships: [], + }, + ]; + return [{ labels }]; + }, configured: () => true, close: async () => {}, envSetting: () => '', @@ -74,6 +100,35 @@ const post = (path, body, type = 'text/plain') => body, }); +describe("Java's answers", () => { + it('writes /ids in the HashSet order, not the order sent; the ids not found left out', async () => { + finds = true; + try { + const response = await post('ids', 'R-HSA-109581, R-HSA-1640170, 199420, xyz'); + expect((await response.json()).map((o) => o.dbId)).toEqual([1640170, 109581, 199420]); + } finally { + finds = false; + } + }); + + it('writes /ids/map keyed by each id as sent, in the HashMap order', async () => { + finds = true; + try { + const response = await post('ids/map', 'R-HSA-109581, R-HSA-1640170, 199420, xyz'); + // Read from the text: JSON.parse would put "199420", a key that looks + // like an index, first whatever order the answer wrote. + const text = await response.text(); + expect([...text.matchAll(/"([^"]+)":\{"dbId":(\d+)/g)].map((m) => [m[1], m[2]])).toEqual([ + ['R-HSA-1640170', '1640170'], + ['R-HSA-109581', '109581'], + ['199420', '199420'], + ]); + } finally { + finds = false; + } + }); +}); + describe("Java's errors", () => { it('refuses a body that is not text/plain, before anything else', async () => { const response = await post('ids', 'x', 'application/json'); diff --git a/tools/content-node/query-search.spec.mjs b/tools/content-node/query-search.spec.mjs index 61551768..1d2ecb53 100644 --- a/tools/content-node/query-search.spec.mjs +++ b/tools/content-node/query-search.spec.mjs @@ -277,6 +277,9 @@ describe('/query', () => { answer = (handler) => (handler.endsWith('/facet') ? facets(8) : grouped([])); const blank = await get('query?query=TP53&scope=%20'); expect(blank.status).toBe(500); + expect((await blank.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); answer = () => ({ response: { numFound: 0, docs: [] } }); const empty = await get('query?query=&scope=%20'); expect(empty.status).toBe(404); @@ -332,12 +335,11 @@ describe('/query', () => { const body = await ( await get('query?query=%20P04637&cluster=false&Start%20row=900&scope=BOTH') ).json(); - expect(body.targetResults).toEqual( - expect.arrayContaining([ - { resource: 'UNIPROT', target: false }, - { term: 'P04637', resource: 'UNIPROT', target: true }, - ]) - ); + // In the order Java's HashSet writes them, not merely both present. + expect(body.targetResults).toEqual([ + { term: 'P04637', resource: 'UNIPROT', target: true }, + { resource: 'UNIPROT', target: false }, + ]); }); it("is Java's 500 when Solr's grouped answer has no grouping", async () => { @@ -345,6 +347,9 @@ describe('/query', () => { handler.endsWith('/facet') ? facets(8) : { response: { numFound: 0, docs: [] } }; const response = await get('query?query=TP53'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it('leaves out the type of a group with none', async () => { @@ -405,6 +410,9 @@ describe('/query/paginated', () => { }; const response = await get('query/paginated?query=TP53&page=1&rowCount=10'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); // This search's own report, not a late one from the test before. await until(() => reports.some((report) => report.body === '{"term":"TP53","resource":""}')); }); @@ -421,6 +429,9 @@ describe('/query/paginated', () => { : grouped([['Protein', [doc('1', 'TP53')]]]); const response = await get('query/paginated?query=TP53&page=1&rowCount=10'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it("is Java's 400 for a page or row count that is not positive, and Spring's when absent", async () => { diff --git a/tools/content-node/references-mapping.spec.mjs b/tools/content-node/references-mapping.spec.mjs index 63f4b645..f62a2521 100644 --- a/tools/content-node/references-mapping.spec.mjs +++ b/tools/content-node/references-mapping.spec.mjs @@ -84,7 +84,7 @@ describe('/references/mapping/{identifier}', () => { const response = await get('P1'); expect((await response.json()).map((e) => e.dbId)).toEqual([1]); expect(last()[0].replace(/\s+/g, ' ').trim()).toMatch( - /^MATCH \(rd:ReferenceDatabase\)<--\(n\{identifier:\$identifier\}\)<-\[:referenceEntity\|referenceSequence\|crossReference\|referenceGene\*\]-\(pe:PhysicalEntity\) WITH DISTINCT pe MATCH \(pe\)-\[:referenceEntity\]->\(n:ReferenceEntity\) ?RETURN DISTINCT n/ + /^MATCH \(rd:ReferenceDatabase\)<--\(n\{identifier:\$identifier\}\)<-\[:referenceEntity\|referenceSequence\|crossReference\|referenceGene\*\]-\(pe:PhysicalEntity\) WITH DISTINCT pe MATCH \(pe\)-\[:referenceEntity\]->\(n:ReferenceEntity\) ?RETURN DISTINCT n, labels\(n\) AS labels$/ ); expect(last()[1]).toEqual({ identifier: 'P1' }); }); diff --git a/tools/content-node/species-search.spec.mjs b/tools/content-node/species-search.spec.mjs index 88ca7c73..e92c6a5c 100644 --- a/tools/content-node/species-search.spec.mjs +++ b/tools/content-node/species-search.spec.mjs @@ -257,7 +257,11 @@ describe('/fireworks, the species-wide search', () => { 500, ['Something unexpected happened and the error has been reported.'], ]); - await new Promise((resolve) => setTimeout(resolve, 50)); + // Reports go in order: once a later search's report has come, one from + // this search would have come before it. + answer = () => ({ response: { numFound: 0, docs: [] } }); + await get('?query=marker&scope=BOTH'); + await until(() => reports.some((report) => report.body.includes('"term":"marker"'))); expect(reports.filter((report) => report.body.includes('"term":"x"'))).toHaveLength(0); }); @@ -313,6 +317,9 @@ describe('/fireworks, the species-wide search', () => { : { response: { numFound: 0, docs: [] } }; const response = await get('?query=x&scope=BOTH'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it('reports the targets in the fresh HashSet Collectors.toSet() makes', async () => { @@ -343,6 +350,9 @@ describe('/fireworks, the species-wide search', () => { expect(missing.status).toBe(400); const blank = await get('?query=x&includeInteractors=%20'); expect(blank.status).toBe(500); + expect((await blank.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); expect(asked).toHaveLength(0); }); From c0e3e8dde487118867cd9c78bab1b7674d5ecea1 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 20:11:54 +0000 Subject: [PATCH 3/6] Java's index failures answered with Java's message; review of the specs The review of this branch found four places answering Java's NullPointerException text where Java throws an ArrayIndexOutOfBoundsException, which GlobalExceptionHandler answers with its own message (handleUnclassified), as node already does for the measured "Index 0 out of bounds for length 0": an occurrence of fewer than four parts (extractRawOccurrence, for /diagram/{d}/occurrences and /diagram/{d}/flag; fireworksFlagging for /fireworks/flag) and an icon entity of fewer than four parts. Each now names the index Java reads first past the end, in the order search-core's bytecode reads them ([0],[2],[3],[1]; [0],[3]; [0]..[3]); fromSolr answers a javaMessage as Java's 500. The spec this branch had pinned to the wrong message is corrected, and each site has a test (all four fail on the code before). - /fireworks/flag with two Species of one name: back to status only -- Java's message there is Spring Data's, and has not been measured. - The citation is asked for by a versioned id, neither its stId nor its dbId, so citing either instead of the id as given fails. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/citation.spec.ts | 9 +++-- tools/content-node/diagram-search.spec.mjs | 21 +++++++--- tools/content-node/flag.spec.mjs | 31 ++++++++++++-- tools/content-node/icon.spec.mjs | 5 ++- tools/content-node/service.ts | 47 +++++++++++++--------- 5 files changed, 79 insertions(+), 34 deletions(-) diff --git a/tools/content-node/citation.spec.ts b/tools/content-node/citation.spec.ts index 639705ad..26366988 100644 --- a/tools/content-node/citation.spec.ts +++ b/tools/content-node/citation.spec.ts @@ -385,10 +385,11 @@ describe('the citation endpoints', () => { const pathway = await ( await fetch(`${base}/ContentService/citation/pathway/9?dateAccessed=d`) ).json(); - // Asked by its stable id: the link names the id as given, not its dbId. + // Asked by a versioned stable id, neither its stId nor its dbId: the + // link names the id as given. globalThis.graphRows = [eventRow('Reaction', ['ReactionLikeEvent', 'Reaction'])]; const reaction = await ( - await fetch(`${base}/ContentService/citation/pathway/R-HSA-9?dateAccessed=d`) + await fetch(`${base}/ContentService/citation/pathway/R-HSA-9.1?dateAccessed=d`) ).json(); expect([pathway, reaction]).toEqual([ { @@ -399,9 +400,9 @@ describe('the citation endpoints', () => { }, { imageCitation: - 'Image Citation for An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9 (d)', + 'Image Citation for An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9.1 (d)', pathwayCitation: - 'The Reactome Consortium (2020). An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9 (d)', + 'The Reactome Consortium (2020). An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9.1 (d)', }, ]); } finally { diff --git a/tools/content-node/diagram-search.spec.mjs b/tools/content-node/diagram-search.spec.mjs index 747dc33e..bf58028d 100644 --- a/tools/content-node/diagram-search.spec.mjs +++ b/tools/content-node/diagram-search.spec.mjs @@ -260,12 +260,21 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { }); const none = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); expect(await none.text()).toBe('{"diagramEntity":"R-HSA-68886","inDiagram":false}'); - answer = () => ({ response: { numFound: 1, docs: [{ occurrences: ['R-HSA-68886:true:x'] }] } }); - const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); - expect(short.status).toBe(500); - expect((await short.json()).messages).toEqual([ - 'Something unexpected happened and the error has been reported.', - ]); + // Java's ArrayIndexOutOfBoundsException, with its own message + // (GlobalExceptionHandler.handleUnclassified): extractRawOccurrence reads + // [0], [2], [3], then [1], so three parts fail at 3, two at 2. + for (const [occurrence, message] of [ + ['R-HSA-68886:true:x', 'Index 3 out of bounds for length 3'], + ['R-HSA-68886:true', 'Index 2 out of bounds for length 2'], + ]) { + answer = () => ({ response: { numFound: 1, docs: [{ occurrences: [occurrence] }] } }); + const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); + expect([occurrence, short.status, (await short.json()).messages]).toEqual([ + occurrence, + 500, + [message], + ]); + } }); it('asks again in every scope, then is the 404', async () => { diff --git a/tools/content-node/flag.spec.mjs b/tools/content-node/flag.spec.mjs index de7ce75a..ac874b49 100644 --- a/tools/content-node/flag.spec.mjs +++ b/tools/content-node/flag.spec.mjs @@ -134,6 +134,18 @@ describe('/diagram/{pathwayId}/flag', () => { ]); }); + it("is Java's index failure for an occurrence of fewer than four parts", async () => { + // extractRawOccurrence reads [0], [2], [3], then [1]: two parts fail at 2. + answer = () => ({ + response: { numFound: 1, docs: [{ stId: 'R-HSA-9', occurrences: ['R-HSA-446203:true'] }] }, + }); + const response = await get('diagram/R-HSA-446203/flag?query=CTSA'); + expect([response.status, (await response.json()).messages]).toEqual([ + 500, + ['Index 2 out of bounds for length 2'], + ]); + }); + it('flags through interactors when asked, and is the 404 for nothing', async () => { asked = []; answer = () => ({ response: { numFound: 0, docs: [] } }); @@ -224,13 +236,24 @@ describe('/fireworks/flag', () => { ]); }); - it("is Java's failure, not a guess, when two Species share the name", async () => { + it('is a failure, before Solr is asked, when two Species share the name', async () => { asked = []; const response = await get('fireworks/flag?query=x&species=Twice'); + // Status only: Java's message here is Spring Data's + // IncorrectResultSizeDataAccessException, which has not been measured. expect(response.status).toBe(500); - expect((await response.json()).messages).toEqual([ - 'Something unexpected happened and the error has been reported.', - ]); expect(asked).toHaveLength(0); }); + + it("is Java's index failure for an occurrence of fewer than four parts", async () => { + // fireworksFlagging reads [0], then [3]: one part fails at 3, not 1. + answer = () => ({ + response: { numFound: 1, docs: [{ llps: ['R-HSA-1'], occurrences: ['R-HSA-1'] }] }, + }); + const response = await get('fireworks/flag?query=x&species=Mus%20musculus'); + expect([response.status, (await response.json()).messages]).toEqual([ + 500, + ['Index 3 out of bounds for length 1'], + ]); + }); }); diff --git a/tools/content-node/icon.spec.mjs b/tools/content-node/icon.spec.mjs index f3dda775..d08339cd 100644 --- a/tools/content-node/icon.spec.mjs +++ b/tools/content-node/icon.spec.mjs @@ -83,7 +83,10 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { }); it('fails, as Java does, on an entity with fewer than four parts', () => { - expect(() => searchEntry({ ...icon, iconPhysicalEntities: ['a#b#c#'] }, {})).toThrow(); + // Its ArrayIndexOutOfBoundsException: read [0] to [3], three parts fail at 3. + expect(() => searchEntry({ ...icon, iconPhysicalEntities: ['a#b#c#'] }, {})).toThrow( + expect.objectContaining({ javaMessage: 'Index 3 out of bounds for length 3' }) + ); }); it('id is the stId when there is one, else the dbId', () => { diff --git a/tools/content-node/service.ts b/tools/content-node/service.ts index b1b66dd4..6a0aeb74 100644 --- a/tools/content-node/service.ts +++ b/tools/content-node/service.ts @@ -6464,8 +6464,11 @@ async function fromSolr(request, path, work) { headers: { 'Retry-After': '5' }, }; console.error(`${path}: ${failure.message}`); - // What Java answers when its own code fails, not Solr: a - // NullPointerException, through GlobalExceptionHandler. + // What Java answers when its own code fails, not Solr: any exception + // but a NullPointerException with its own message (handleUnclassified), + // a NullPointerException with its fixed one, through GlobalExceptionHandler. + if (failure.javaMessage) + return javaError(request, 500, 'INTERNAL_SERVER_ERROR', failure.javaMessage); if (failure.unexpected) return javaError( request, @@ -6725,11 +6728,9 @@ function iconPhysicalEntities(doc) { // what is written, but the TreeSet still orders by it. const names: string[] = []; for (const packed of solrStrings(doc, 'iconPhysicalEntities')) { - const [stId, type, name, compartments] = javaSplit(packed, '#'); - if (compartments === undefined) - throw Object.assign(new Error('an icon physical entity with fewer than four parts'), { - unexpected: true, - }); + const split = javaSplit(packed, '#'); + const [stId, type, name, compartments] = split; + if (compartments === undefined) throw outOfBounds(split.length, [0, 1, 2, 3]); const displayName = name + (javaIsBlank(compartments) ? '' : ` [${compartments}]`); const at = names.findIndex((other) => caseInsensitive(displayName, other) <= 0); if (at > -1 && caseInsensitive(displayName, names[at]) === 0) continue; @@ -7102,6 +7103,22 @@ function speciesWideParams(query): SolrParams { ]; } +/** + * Java's ArrayIndexOutOfBoundsException for an array of `length` read at + * `reads`, in the order Java reads them (search-core's bytecode): the first + * index past the end is the one named. GlobalExceptionHandler answers it + * with that message (handleUnclassified), not the NullPointerException's. + */ +function outOfBounds(length: number, reads: number[]): Error { + const index = reads.find((at) => at >= length) ?? length; + return Object.assign(new Error(`a split with ${length} parts, read at ${index}`), { + javaMessage: `Index ${index} out of bounds for length ${length}`, + }); +} + +/** The parts extractRawOccurrence reads, in its order: [0], [2], [3], then [1]. */ +const OCCURRENCE_READS = [0, 2, 3, 1]; + /** * SolrConverter's extractRawOccurrence: "diagram:inDiagram:occurrences: * interactsWith", '#' for none, as a DiagramOccurrencesResult -- each list a @@ -7110,10 +7127,7 @@ function speciesWideParams(query): SolrParams { */ function rawOccurrence(raw) { const line = javaSplit(raw, ':'); - if (line.length < 4) - throw Object.assign(new Error(`an occurrence with fewer than four parts: ${raw}`), { - unexpected: true, - }); + if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS); const set = (text) => { if (text === '#') return undefined; const map = new JavaHashMap(); @@ -7178,10 +7192,7 @@ function diagramFlags(answer, pathway, field) { for (const raw of solrStrings(doc, field)) { if (!raw.startsWith(pathway)) continue; const line = javaSplit(raw, ':'); - if (line.length < 4) - throw Object.assign(new Error(`an occurrence with fewer than four parts: ${raw}`), { - unexpected: true, - }); + if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS); // extractRawOccurrence with the document: the entity is its stId // when it is in the diagram, and in the diagram if that is non-empty. const entity = line[1].toLowerCase() === 'true' ? solrValue(doc, 'stId') : null; @@ -7220,10 +7231,8 @@ function speciesWideFlags(answer, abbreviation) { for (const id of solrStrings(doc, 'llps')) llps.set(id, true); for (const raw of solrStrings(doc, 'occurrences')) { const line = javaSplit(raw, ':'); - if (line.length < 4) - throw Object.assign(new Error(`an occurrence with fewer than four parts: ${raw}`), { - unexpected: true, - }); + // fireworksFlagging reads [0], then [3]. + if (line.length < 4) throw outOfBounds(line.length, [0, 3]); if (line[3] !== '#') interactsWith.set(line[0], true); } } From 2de7e358f613c7e0773454538dcce2828df4f673 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 20:23:44 +0000 Subject: [PATCH 4/6] citation.spec: the stand-in's state and server typed The new tests added uses of an untyped `base` and of untyped globals, raising the spec's implicit any (28 -> 34); typing them takes the module down instead, and the services' total 812 -> 794. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/citation.spec.ts | 26 +++++++++++++++----------- tools/implicit-any-baseline.json | 2 +- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/tools/content-node/citation.spec.ts b/tools/content-node/citation.spec.ts index 26366988..a4cfc22b 100644 --- a/tools/content-node/citation.spec.ts +++ b/tools/content-node/citation.spec.ts @@ -288,17 +288,21 @@ vi.mock('./graph.ts', async () => ({ // The release as the service reads it: releaseNumber, not another property. if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1') return [{ version: 97 }]; - globalThis.graphAsked = params; - return globalThis.graphRows ?? []; + const shared = globalThis as { graphAsked?: unknown; graphRows?: unknown[] }; + shared.graphAsked = params; + return shared.graphRows ?? []; }, configured: () => true, close: async () => {}, envSetting: (key, fallback = '') => process.env[key] || fallback, })); +/** What the stand-in graph answers, and what it was last asked. */ +const graph = globalThis as { graphAsked?: unknown; graphRows?: unknown[] }; + describe('the citation endpoints', () => { - let base; - let server; + let base: string; + let server: Server; let pmc: Server; let pmcAsked: (string | undefined)[] = []; @@ -345,11 +349,11 @@ describe('the citation endpoints', () => { }); it('/pathway/{id} is {} for an id that is no event, and export an empty 200', async () => { - globalThis.graphRows = []; + graph.graphRows = []; const pathway = await fetch(`${base}/ContentService/citation/pathway/R-HSA-9?dateAccessed=d`); expect([pathway.status, await pathway.text()]).toEqual([200, '{}']); // The id from the path, not the path's own words. - expect(globalThis.graphAsked).toEqual({ id: 'R-HSA-9' }); + expect(graph.graphAsked).toEqual({ id: 'R-HSA-9' }); const exported = await fetch( `${base}/ContentService/citation/export?isPathway=true&id=xyz&ext=bib&dateAccessed=d` ); @@ -381,13 +385,13 @@ describe('the citation endpoints', () => { it("/pathway/{id} cites the event by the id as given; only a pathway's by its DOI", async () => { try { - globalThis.graphRows = [eventRow('Pathway', ['Pathway'])]; + graph.graphRows = [eventRow('Pathway', ['Pathway'])]; const pathway = await ( await fetch(`${base}/ContentService/citation/pathway/9?dateAccessed=d`) ).json(); // Asked by a versioned stable id, neither its stId nor its dbId: the // link names the id as given. - globalThis.graphRows = [eventRow('Reaction', ['ReactionLikeEvent', 'Reaction'])]; + graph.graphRows = [eventRow('Reaction', ['ReactionLikeEvent', 'Reaction'])]; const reaction = await ( await fetch(`${base}/ContentService/citation/pathway/R-HSA-9.1?dateAccessed=d`) ).json(); @@ -406,7 +410,7 @@ describe('the citation endpoints', () => { }, ]); } finally { - globalThis.graphRows = []; + graph.graphRows = []; } }); @@ -544,7 +548,7 @@ describe('the citation endpoints', () => { it("/pathway/{id} is Java's 500 for an event it cannot cite", async () => { // A reaction with no release date: Java's getReleaseDate().substring NPEs. - globalThis.graphRows = [ + graph.graphRows = [ { root: { id: 1, @@ -564,7 +568,7 @@ describe('the citation endpoints', () => { ['Something unexpected happened and the error has been reported.'], ]); } finally { - globalThis.graphRows = []; + graph.graphRows = []; } }); diff --git a/tools/implicit-any-baseline.json b/tools/implicit-any-baseline.json index 0f4583c1..2fd9b244 100644 --- a/tools/implicit-any-baseline.json +++ b/tools/implicit-any-baseline.json @@ -1,5 +1,5 @@ { - "tools/content-node/citation.spec.ts": 28, + "tools/content-node/citation.spec.ts": 10, "tools/content-node/citation.ts": 44, "tools/content-node/domain.spec.ts": 16, "tools/content-node/domain.ts": 131, From 97c0661eb052718780aca516f596904e56749b90 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 20:36:18 +0000 Subject: [PATCH 5/6] Review of #576: a one-part occurrence pins Java's read order; the raw value logged Three- and two-part occurrences fail at the same index whatever the read order; one part fails at 2 in Java's ([0],[2],[3],[1]) and at 1 in a naive one, so the spec now holds the order (shown failing under [0,1,2,3]). The log line names the raw occurrence again, to find the document by. Co-Authored-By: Claude Opus 5.5 --- tools/content-node/diagram-search.spec.mjs | 4 +++- tools/content-node/service.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/content-node/diagram-search.spec.mjs b/tools/content-node/diagram-search.spec.mjs index bf58028d..02b45348 100644 --- a/tools/content-node/diagram-search.spec.mjs +++ b/tools/content-node/diagram-search.spec.mjs @@ -262,10 +262,12 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { expect(await none.text()).toBe('{"diagramEntity":"R-HSA-68886","inDiagram":false}'); // Java's ArrayIndexOutOfBoundsException, with its own message // (GlobalExceptionHandler.handleUnclassified): extractRawOccurrence reads - // [0], [2], [3], then [1], so three parts fail at 3, two at 2. + // [0], [2], [3], then [1], so three parts fail at 3, two at 2 -- and + // one at 2, not 1: the order is Java's, not the parts'. for (const [occurrence, message] of [ ['R-HSA-68886:true:x', 'Index 3 out of bounds for length 3'], ['R-HSA-68886:true', 'Index 2 out of bounds for length 2'], + ['R-HSA-68886', 'Index 2 out of bounds for length 1'], ]) { answer = () => ({ response: { numFound: 1, docs: [{ occurrences: [occurrence] }] } }); const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); diff --git a/tools/content-node/service.ts b/tools/content-node/service.ts index 6a0aeb74..228e78fe 100644 --- a/tools/content-node/service.ts +++ b/tools/content-node/service.ts @@ -6730,7 +6730,7 @@ function iconPhysicalEntities(doc) { for (const packed of solrStrings(doc, 'iconPhysicalEntities')) { const split = javaSplit(packed, '#'); const [stId, type, name, compartments] = split; - if (compartments === undefined) throw outOfBounds(split.length, [0, 1, 2, 3]); + if (compartments === undefined) throw outOfBounds(split.length, [0, 1, 2, 3], packed); const displayName = name + (javaIsBlank(compartments) ? '' : ` [${compartments}]`); const at = names.findIndex((other) => caseInsensitive(displayName, other) <= 0); if (at > -1 && caseInsensitive(displayName, names[at]) === 0) continue; @@ -7109,9 +7109,9 @@ function speciesWideParams(query): SolrParams { * index past the end is the one named. GlobalExceptionHandler answers it * with that message (handleUnclassified), not the NullPointerException's. */ -function outOfBounds(length: number, reads: number[]): Error { +function outOfBounds(length: number, reads: number[], raw: string): Error { const index = reads.find((at) => at >= length) ?? length; - return Object.assign(new Error(`a split with ${length} parts, read at ${index}`), { + return Object.assign(new Error(`${length} parts, read at ${index}: ${raw}`), { javaMessage: `Index ${index} out of bounds for length ${length}`, }); } @@ -7127,7 +7127,7 @@ const OCCURRENCE_READS = [0, 2, 3, 1]; */ function rawOccurrence(raw) { const line = javaSplit(raw, ':'); - if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS); + if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS, raw); const set = (text) => { if (text === '#') return undefined; const map = new JavaHashMap(); @@ -7192,7 +7192,7 @@ function diagramFlags(answer, pathway, field) { for (const raw of solrStrings(doc, field)) { if (!raw.startsWith(pathway)) continue; const line = javaSplit(raw, ':'); - if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS); + if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS, raw); // extractRawOccurrence with the document: the entity is its stId // when it is in the diagram, and in the diagram if that is non-empty. const entity = line[1].toLowerCase() === 'true' ? solrValue(doc, 'stId') : null; @@ -7232,7 +7232,7 @@ function speciesWideFlags(answer, abbreviation) { for (const raw of solrStrings(doc, 'occurrences')) { const line = javaSplit(raw, ':'); // fireworksFlagging reads [0], then [3]. - if (line.length < 4) throw outOfBounds(line.length, [0, 3]); + if (line.length < 4) throw outOfBounds(line.length, [0, 3], raw); if (line[3] !== '#') interactsWith.set(line[0], true); } } From 37352e3d2be907d1586fca62db001cfb2b44079a Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 21:06:42 +0000 Subject: [PATCH 6/6] javaSplit typed; four parts read through one helper After #575 the loose check held service.ts to its locked-in count, and this branch's outOfBounds calls had raised it (1529 -> 1539): javaSplit returned an untyped value. Typed, with fourParts (the first four parts, or Java's index failure in the order given) at the four sites, the count falls instead: loose 2769 -> 2709, implicit any 794 -> 789, both locked in. Answers on beta unchanged (flag and Solr capture sets identical). Co-Authored-By: Claude Opus 5.5 --- tools/content-node/service.ts | 33 +++++++++++++++++++++----------- tools/implicit-any-baseline.json | 2 +- tools/loose-baseline.json | 4 ++-- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/tools/content-node/service.ts b/tools/content-node/service.ts index ca2f2af0..17c23e61 100644 --- a/tools/content-node/service.ts +++ b/tools/content-node/service.ts @@ -6701,7 +6701,7 @@ function solrStrings(doc: SolrDoc, field: string) { } /** String.split on a literal or a pattern, as Java's: trailing empty strings removed. */ -function javaSplit(text, separator) { +function javaSplit(text: string, separator: string | RegExp): string[] { const parts = text.split(separator); // No separator at all is the text itself, even empty; otherwise every // trailing empty string goes, so "," is nothing at all. @@ -6728,9 +6728,7 @@ function iconPhysicalEntities(doc) { // what is written, but the TreeSet still orders by it. const names: string[] = []; for (const packed of solrStrings(doc, 'iconPhysicalEntities')) { - const split = javaSplit(packed, '#'); - const [stId, type, name, compartments] = split; - if (compartments === undefined) throw outOfBounds(split.length, [0, 1, 2, 3], packed); + const [stId, type, name, compartments] = fourParts(packed, '#', [0, 1, 2, 3]); const displayName = name + (javaIsBlank(compartments) ? '' : ` [${compartments}]`); const at = names.findIndex((other) => caseInsensitive(displayName, other) <= 0); if (at > -1 && caseInsensitive(displayName, names[at]) === 0) continue; @@ -7116,6 +7114,22 @@ function outOfBounds(length: number, reads: number[], raw: string): Error { }); } +/** + * The first four parts of `raw` split as Java splits it, or, for fewer, + * Java's failure reading them in the order `reads` (outOfBounds). + */ +function fourParts( + raw: string, + separator: string, + reads: number[] +): [string, string, string, string] { + const parts = javaSplit(raw, separator); + const [a, b, c, d] = parts; + if (a === undefined || b === undefined || c === undefined || d === undefined) + throw outOfBounds(parts.length, reads, raw); + return [a, b, c, d]; +} + /** The parts extractRawOccurrence reads, in its order: [0], [2], [3], then [1]. */ const OCCURRENCE_READS = [0, 2, 3, 1]; @@ -7125,9 +7139,8 @@ const OCCURRENCE_READS = [0, 2, 3, 1]; * HashSet, so written in a java.util.HashMap's order, and left out empty. * Fewer than four parts is Java's ArrayIndexOutOfBoundsException. */ -function rawOccurrence(raw) { - const line = javaSplit(raw, ':'); - if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS, raw); +function rawOccurrence(raw: string) { + const line = fourParts(raw, ':', OCCURRENCE_READS); const set = (text) => { if (text === '#') return undefined; const map = new JavaHashMap(); @@ -7191,8 +7204,7 @@ function diagramFlags(answer, pathway, field) { for (const doc of answer?.response?.docs ?? []) { for (const raw of solrStrings(doc, field)) { if (!raw.startsWith(pathway)) continue; - const line = javaSplit(raw, ':'); - if (line.length < 4) throw outOfBounds(line.length, OCCURRENCE_READS, raw); + const line = fourParts(raw, ':', OCCURRENCE_READS); // extractRawOccurrence with the document: the entity is its stId // when it is in the diagram, and in the diagram if that is non-empty. const entity = line[1].toLowerCase() === 'true' ? solrValue(doc, 'stId') : null; @@ -7230,9 +7242,8 @@ function speciesWideFlags(answer, abbreviation) { for (const doc of answer?.response?.docs ?? []) { for (const id of solrStrings(doc, 'llps')) llps.set(id, true); for (const raw of solrStrings(doc, 'occurrences')) { - const line = javaSplit(raw, ':'); // fireworksFlagging reads [0], then [3]. - if (line.length < 4) throw outOfBounds(line.length, [0, 3], raw); + const line = fourParts(raw, ':', [0, 3]); if (line[3] !== '#') interactsWith.set(line[0], true); } } diff --git a/tools/implicit-any-baseline.json b/tools/implicit-any-baseline.json index 2fd9b244..1092dfc6 100644 --- a/tools/implicit-any-baseline.json +++ b/tools/implicit-any-baseline.json @@ -5,7 +5,7 @@ "tools/content-node/domain.ts": 131, "tools/content-node/java-hashmap.ts": 26, "tools/content-node/parameters.ts": 25, - "tools/content-node/service.ts": 429, + "tools/content-node/service.ts": 424, "tools/render/pptx.ts": 72, "tools/render/render-core.ts": 18, "tools/render/service.ts": 23 diff --git a/tools/loose-baseline.json b/tools/loose-baseline.json index b2105abe..e35fe5d6 100644 --- a/tools/loose-baseline.json +++ b/tools/loose-baseline.json @@ -1,5 +1,5 @@ { - "tools/content-node/citation.spec.ts": 13, + "tools/content-node/citation.spec.ts": 6, "tools/content-node/citation.ts": 170, "tools/content-node/classify.ts": 18, "tools/content-node/domain.spec.ts": 77, @@ -11,7 +11,7 @@ "tools/content-node/parameters.ts": 48, "tools/content-node/psicquic.spec.ts": 1, "tools/content-node/report.ts": 3, - "tools/content-node/service.ts": 1529, + "tools/content-node/service.ts": 1476, "tools/content-node/snapshot-openapi.spec.ts": 5, "tools/content-node/snapshot-rfd.ts": 4, "tools/content-node/snapshot-schema.ts": 11,