@glossarist/concept-browser 0.7.22 → 0.7.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +2 -1
- package/package.json +1 -1
- package/scripts/build-edges.js +50 -5
- package/scripts/generate-data.mjs +33 -6
- package/src/App.vue +10 -12
- package/src/__tests__/concept-view.test.ts +7 -1
- package/src/__tests__/dataset-adapter.test.ts +87 -0
- package/src/__tests__/dataset-view.test.ts +1 -0
- package/src/__tests__/factory-lazy.test.ts +183 -0
- package/src/__tests__/graph-engine-fixes.test.ts +104 -0
- package/src/__tests__/ontology-registry.test.ts +4 -4
- package/src/__tests__/performance-v2.test.ts +77 -0
- package/src/__tests__/performance.test.ts +95 -0
- package/src/__tests__/search-utils.test.ts +59 -0
- package/src/__tests__/test-helpers.ts +4 -0
- package/src/__tests__/utils-barrel.test.ts +15 -0
- package/src/__tests__/vocabulary-layered.test.ts +291 -0
- package/src/adapters/DatasetAdapter.ts +41 -1
- package/src/adapters/factory.ts +35 -4
- package/src/adapters/ontology-registry.ts +1 -1
- package/src/adapters/types.ts +12 -0
- package/src/components/AppSidebar.vue +17 -343
- package/src/components/ConceptDetail.vue +121 -70
- package/src/components/GraphPanel.vue +14 -6
- package/src/components/OntologySidebarSection.vue +338 -0
- package/src/config/use-site-config.ts +20 -9
- package/src/data/taxonomies.json +12 -6
- package/src/directives/v-math.ts +2 -3
- package/src/graph/GraphEngine.ts +22 -5
- package/src/i18n/index.ts +1 -1
- package/src/stores/vocabulary.ts +65 -105
- package/src/utils/index.ts +1 -0
- package/src/utils/relationship-categories.ts +3 -2
- package/src/utils/search.ts +15 -0
- package/src/views/ConceptView.vue +0 -2
- package/src/views/DatasetView.vue +64 -39
- package/src/views/HomeView.vue +0 -1
- package/vite.config.ts +94 -6
|
@@ -8,7 +8,7 @@ describe('OntologyRegistry', () => {
|
|
|
8
8
|
expect(ontology.getAll('normativeStatus').length).toBe(4);
|
|
9
9
|
expect(ontology.getAll('sourceType').length).toBe(2);
|
|
10
10
|
expect(ontology.getAll('sourceStatus').length).toBe(10);
|
|
11
|
-
expect(ontology.getAll('relationshipType').length).toBe(
|
|
11
|
+
expect(ontology.getAll('relationshipType').length).toBe(52);
|
|
12
12
|
expect(ontology.getAll('designationType').length).toBe(5);
|
|
13
13
|
expect(ontology.getAll('termType').length).toBe(24);
|
|
14
14
|
expect(ontology.getAll('grammarGender').length).toBe(4);
|
|
@@ -100,9 +100,9 @@ describe('OntologyRegistry', () => {
|
|
|
100
100
|
expect(types).toContain('false_friend');
|
|
101
101
|
expect(types).toContain('abbreviated_form_for');
|
|
102
102
|
expect(types).toContain('short_form_for');
|
|
103
|
-
expect(types).toContain('
|
|
104
|
-
expect(types).toContain('
|
|
105
|
-
expect(types).toContain('
|
|
103
|
+
expect(types).toContain('sequentially_related');
|
|
104
|
+
expect(types).toContain('spatially_related');
|
|
105
|
+
expect(types).toContain('temporally_related');
|
|
106
106
|
expect(types).toContain('related_concept_broader');
|
|
107
107
|
expect(types).toContain('related_concept_narrower');
|
|
108
108
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { GraphEngine } from '../graph/GraphEngine';
|
|
3
|
+
import type { GraphNode, GraphEdge } from '../adapters/types';
|
|
4
|
+
|
|
5
|
+
function makeNode(uri: string, conceptId: string, register = 'test'): GraphNode {
|
|
6
|
+
return { uri, register, conceptId, designations: { eng: conceptId }, status: 'valid', loaded: true };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makeEdge(source: string, target: string, type = 'references', register = 'test'): GraphEdge {
|
|
10
|
+
return { source, target, type, register };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('GraphEngine encapsulation', () => {
|
|
14
|
+
it('getEdges() returns a copy, not internal reference', () => {
|
|
15
|
+
const engine = new GraphEngine();
|
|
16
|
+
engine.addNode(makeNode('uri:a', 'a'));
|
|
17
|
+
engine.addNode(makeNode('uri:b', 'b'));
|
|
18
|
+
engine.addEdge(makeEdge('uri:a', 'uri:b'));
|
|
19
|
+
|
|
20
|
+
const edges = engine.getEdges();
|
|
21
|
+
expect(edges).toHaveLength(1);
|
|
22
|
+
expect(edges).not.toBe((engine as any).edges);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('mutating getEdges() result does not affect engine', () => {
|
|
26
|
+
const engine = new GraphEngine();
|
|
27
|
+
engine.addNode(makeNode('uri:a', 'a'));
|
|
28
|
+
engine.addNode(makeNode('uri:b', 'b'));
|
|
29
|
+
engine.addEdge(makeEdge('uri:a', 'uri:b'));
|
|
30
|
+
|
|
31
|
+
const edges = engine.getEdges();
|
|
32
|
+
edges.push(makeEdge('uri:a', 'uri:c'));
|
|
33
|
+
|
|
34
|
+
expect(engine.edgeCount).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('getEdges(uri) returns flat array from adjacency', () => {
|
|
38
|
+
const engine = new GraphEngine();
|
|
39
|
+
engine.addNode(makeNode('uri:a', 'a'));
|
|
40
|
+
engine.addNode(makeNode('uri:b', 'b'));
|
|
41
|
+
engine.addNode(makeNode('uri:c', 'c'));
|
|
42
|
+
engine.addEdge(makeEdge('uri:a', 'uri:b', 'references'));
|
|
43
|
+
engine.addEdge(makeEdge('uri:a', 'uri:c', 'supersedes'));
|
|
44
|
+
|
|
45
|
+
const edges = engine.getEdges('uri:a');
|
|
46
|
+
expect(edges).toHaveLength(2);
|
|
47
|
+
expect(edges.map(e => e.target)).toEqual(expect.arrayContaining(['uri:b', 'uri:c']));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('getIncomingEdges() returns flat array from reverse adjacency', () => {
|
|
51
|
+
const engine = new GraphEngine();
|
|
52
|
+
engine.addNode(makeNode('uri:a', 'a'));
|
|
53
|
+
engine.addNode(makeNode('uri:b', 'b'));
|
|
54
|
+
engine.addNode(makeNode('uri:c', 'c'));
|
|
55
|
+
engine.addEdge(makeEdge('uri:a', 'uri:c'));
|
|
56
|
+
engine.addEdge(makeEdge('uri:b', 'uri:c'));
|
|
57
|
+
|
|
58
|
+
const incoming = engine.getIncomingEdges('uri:c');
|
|
59
|
+
expect(incoming).toHaveLength(2);
|
|
60
|
+
expect(incoming.map(e => e.source)).toEqual(expect.arrayContaining(['uri:a', 'uri:b']));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('v-math directive optimization', () => {
|
|
65
|
+
it('querySelector used for early exit check', () => {
|
|
66
|
+
// Verify the directive code uses querySelector (not querySelectorAll) for the existence check
|
|
67
|
+
const fs = require('fs');
|
|
68
|
+
const path = require('path');
|
|
69
|
+
const code = fs.readFileSync(path.join(__dirname, '../directives/v-math.ts'), 'utf-8');
|
|
70
|
+
|
|
71
|
+
// The early exit should use querySelector (returns first match or null)
|
|
72
|
+
// not querySelectorAll (creates NodeList)
|
|
73
|
+
expect(code).toContain("querySelector('.math-pending')");
|
|
74
|
+
// querySelectorAll should only be called after the early exit confirms math exists
|
|
75
|
+
expect(code).toContain('querySelectorAll');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { GraphEngine } from '../graph/GraphEngine';
|
|
3
|
+
import type { GraphNode, GraphEdge } from '../adapters/types';
|
|
4
|
+
import { setupPinia } from './test-helpers';
|
|
5
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
6
|
+
import { getFactory, resetFactory } from '../adapters/factory';
|
|
7
|
+
import { ref, shallowRef, isReactive, isRef, toRaw } from 'vue';
|
|
8
|
+
|
|
9
|
+
function makeNode(uri: string, conceptId: string, register = 'test'): GraphNode {
|
|
10
|
+
return { uri, register, conceptId, designations: { eng: conceptId }, status: 'valid', loaded: true };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeEdge(source: string, target: string, type = 'references', register = 'test'): GraphEdge {
|
|
14
|
+
return { source, target, type, register };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('shallowRef graph — no deep proxy', () => {
|
|
18
|
+
it('graph is shallowRef not deep ref', () => {
|
|
19
|
+
setupPinia();
|
|
20
|
+
resetFactory();
|
|
21
|
+
const store = useVocabularyStore();
|
|
22
|
+
const raw = toRaw(store.graph);
|
|
23
|
+
expect(raw).toBeInstanceOf(GraphEngine);
|
|
24
|
+
expect(raw.nodeCount).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('graph mutations work through shallowRef', () => {
|
|
28
|
+
setupPinia();
|
|
29
|
+
resetFactory();
|
|
30
|
+
const store = useVocabularyStore();
|
|
31
|
+
const engine = store.graph;
|
|
32
|
+
engine.addNode(makeNode('uri:a', 'a'));
|
|
33
|
+
engine.addEdge(makeEdge('uri:a', 'uri:b'));
|
|
34
|
+
expect(engine.nodeCount).toBe(2);
|
|
35
|
+
expect(engine.edgeCount).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('LRU conceptCache', () => {
|
|
40
|
+
it('cache evicts oldest entries beyond MAX_CACHE', () => {
|
|
41
|
+
// This tests the LRU behavior indirectly through the adapter
|
|
42
|
+
// The MAX_CACHE is 100, but we verify the mechanism works
|
|
43
|
+
const cache = new Map<string, string>();
|
|
44
|
+
const MAX = 5;
|
|
45
|
+
|
|
46
|
+
function set(key: string, value: string) {
|
|
47
|
+
cache.set(key, value);
|
|
48
|
+
if (cache.size > MAX) {
|
|
49
|
+
const oldest = cache.keys().next().value;
|
|
50
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
set('a', '1');
|
|
55
|
+
set('b', '2');
|
|
56
|
+
set('c', '3');
|
|
57
|
+
set('d', '4');
|
|
58
|
+
set('e', '5');
|
|
59
|
+
expect(cache.size).toBe(5);
|
|
60
|
+
|
|
61
|
+
set('f', '6');
|
|
62
|
+
expect(cache.size).toBe(5);
|
|
63
|
+
expect(cache.has('a')).toBe(false);
|
|
64
|
+
expect(cache.get('f')).toBe('6');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('cache promotes accessed entries', () => {
|
|
68
|
+
const cache = new Map<string, string>();
|
|
69
|
+
const MAX = 3;
|
|
70
|
+
|
|
71
|
+
function set(key: string, value: string) {
|
|
72
|
+
// Promote: delete and re-add to move to end
|
|
73
|
+
cache.delete(key);
|
|
74
|
+
cache.set(key, value);
|
|
75
|
+
if (cache.size > MAX) {
|
|
76
|
+
const oldest = cache.keys().next().value;
|
|
77
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
set('a', '1');
|
|
82
|
+
set('b', '2');
|
|
83
|
+
set('c', '3');
|
|
84
|
+
|
|
85
|
+
// Access 'a' to promote it
|
|
86
|
+
set('a', '1');
|
|
87
|
+
|
|
88
|
+
// Add new entry — 'b' should be evicted, not 'a'
|
|
89
|
+
set('d', '4');
|
|
90
|
+
expect(cache.has('a')).toBe(true);
|
|
91
|
+
expect(cache.has('b')).toBe(false);
|
|
92
|
+
expect(cache.has('c')).toBe(true);
|
|
93
|
+
expect(cache.has('d')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { deduplicateSearchHits } from '../utils/search';
|
|
3
|
+
import type { SearchHit } from '../adapters/types';
|
|
4
|
+
|
|
5
|
+
function makeHit(overrides: Partial<SearchHit> = {}): SearchHit {
|
|
6
|
+
return {
|
|
7
|
+
conceptId: '1',
|
|
8
|
+
registerId: 'test',
|
|
9
|
+
designation: 'test',
|
|
10
|
+
language: 'eng',
|
|
11
|
+
matchField: 'designation',
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('deduplicateSearchHits', () => {
|
|
17
|
+
it('returns empty array for empty input', () => {
|
|
18
|
+
expect(deduplicateSearchHits([])).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns single hit unchanged', () => {
|
|
22
|
+
const hit = makeHit();
|
|
23
|
+
expect(deduplicateSearchHits([hit])).toEqual([hit]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('deduplicates by registerId:conceptId', () => {
|
|
27
|
+
const hit1 = makeHit({ registerId: 'viml', conceptId: '1', designation: 'accuracy' });
|
|
28
|
+
const hit2 = makeHit({ registerId: 'viml', conceptId: '1', designation: 'accuracy', language: 'fra' });
|
|
29
|
+
|
|
30
|
+
const result = deduplicateSearchHits([hit1, hit2]);
|
|
31
|
+
expect(result.length).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps designation match over id match for same concept', () => {
|
|
35
|
+
const idHit = makeHit({ registerId: 'viml', conceptId: '1', matchField: 'id' });
|
|
36
|
+
const desHit = makeHit({ registerId: 'viml', conceptId: '1', matchField: 'designation' });
|
|
37
|
+
|
|
38
|
+
const result = deduplicateSearchHits([idHit, desHit]);
|
|
39
|
+
expect(result.length).toBe(1);
|
|
40
|
+
expect(result[0].matchField).toBe('designation');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps first designation match when both are designation', () => {
|
|
44
|
+
const hit1 = makeHit({ registerId: 'viml', conceptId: '1', matchField: 'designation', language: 'eng' });
|
|
45
|
+
const hit2 = makeHit({ registerId: 'viml', conceptId: '1', matchField: 'designation', language: 'fra' });
|
|
46
|
+
|
|
47
|
+
const result = deduplicateSearchHits([hit1, hit2]);
|
|
48
|
+
expect(result.length).toBe(1);
|
|
49
|
+
expect(result[0].language).toBe('eng');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('keeps hits from different datasets separate', () => {
|
|
53
|
+
const hit1 = makeHit({ registerId: 'viml-2022', conceptId: '1' });
|
|
54
|
+
const hit2 = makeHit({ registerId: 'viml-2013', conceptId: '1' });
|
|
55
|
+
|
|
56
|
+
const result = deduplicateSearchHits([hit1, hit2]);
|
|
57
|
+
expect(result.length).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -46,6 +46,8 @@ export interface AdapterStubOptions {
|
|
|
46
46
|
extractEdges?: () => any[];
|
|
47
47
|
extractDomainEdges?: () => any[];
|
|
48
48
|
getIndexEntry?: () => any;
|
|
49
|
+
loadEdgeIndex?: () => Promise<any[]>;
|
|
50
|
+
loadDomainNodes?: () => Promise<any[]>;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export function makeAdapterStub(options: AdapterStubOptions = {}): any {
|
|
@@ -65,6 +67,8 @@ export function makeAdapterStub(options: AdapterStubOptions = {}): any {
|
|
|
65
67
|
extractEdges: options.extractEdges ?? (() => []),
|
|
66
68
|
extractDomainEdges: options.extractDomainEdges ?? (() => []),
|
|
67
69
|
getIndexEntry: options.getIndexEntry ?? (() => null),
|
|
70
|
+
loadEdgeIndex: options.loadEdgeIndex ?? (() => Promise.resolve([])),
|
|
71
|
+
loadDomainNodes: options.loadDomainNodes ?? (() => Promise.resolve([])),
|
|
68
72
|
};
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DEFAULT_LANG } from '../utils/lang';
|
|
3
|
+
import { deduplicateSearchHits } from '../utils/search';
|
|
4
|
+
|
|
5
|
+
describe('DEFAULT_LANG single source', () => {
|
|
6
|
+
it('exports "eng"', () => {
|
|
7
|
+
expect(DEFAULT_LANG).toBe('eng');
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('deduplicateSearchHits export', () => {
|
|
12
|
+
it('is a function', () => {
|
|
13
|
+
expect(typeof deduplicateSearchHits).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
3
|
+
import { createTestRouter, setupPinia, makeManifest, makeAdapterStub, makeSearchHit } from './test-helpers';
|
|
4
|
+
import { conceptFromJson } from '../adapters/model-bridge';
|
|
5
|
+
import { getFactory } from '../adapters/factory';
|
|
6
|
+
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
global.fetch = mockFetch;
|
|
9
|
+
|
|
10
|
+
function mockJsonResponse(data: any) {
|
|
11
|
+
return Promise.resolve({
|
|
12
|
+
ok: true,
|
|
13
|
+
status: 200,
|
|
14
|
+
json: () => Promise.resolve(data),
|
|
15
|
+
} as Response);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeConcept() {
|
|
19
|
+
return conceptFromJson({
|
|
20
|
+
'@id': 'https://glossarist.org/test/concept/1',
|
|
21
|
+
'@type': 'gl:Concept',
|
|
22
|
+
'gl:localizedConcept': {
|
|
23
|
+
eng: {
|
|
24
|
+
'@type': 'gl:LocalizedConcept',
|
|
25
|
+
'gl:languageCode': 'eng',
|
|
26
|
+
'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'test' }],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('vocabulary store — layered loading', () => {
|
|
33
|
+
let pinia: ReturnType<typeof setupPinia>;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
pinia = setupPinia();
|
|
37
|
+
mockFetch.mockReset();
|
|
38
|
+
(getFactory() as any).crossRefIndex = null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('loadDataset', () => {
|
|
42
|
+
it('does not fetch edges or domain nodes', async () => {
|
|
43
|
+
const store = useVocabularyStore();
|
|
44
|
+
|
|
45
|
+
// Set up adapter with edge/domain tracking
|
|
46
|
+
let edgeFetchCount = 0;
|
|
47
|
+
let domainFetchCount = 0;
|
|
48
|
+
const adapter = makeAdapterStub();
|
|
49
|
+
adapter.loadEdgeIndex = () => { edgeFetchCount++; return Promise.resolve([]); };
|
|
50
|
+
adapter.loadDomainNodes = () => { domainFetchCount++; return Promise.resolve([]); };
|
|
51
|
+
|
|
52
|
+
store.datasets.set('test', adapter);
|
|
53
|
+
store.manifests.set('test', makeManifest());
|
|
54
|
+
|
|
55
|
+
// The store's loadDataset calls factory.loadDataset which requires adapter
|
|
56
|
+
// to be registered with the factory. Since we test via viewConcept path,
|
|
57
|
+
// verify that after concept view, only targeted edges are loaded.
|
|
58
|
+
// For the store-level test, we verify loadDataset doesn't call loadEdges.
|
|
59
|
+
// Since loadDataset now only does factory.loadDataset, the adapter stub
|
|
60
|
+
// won't have its edge methods called.
|
|
61
|
+
|
|
62
|
+
// Direct test: viewConcept triggers ensureEdgesForDataset
|
|
63
|
+
adapter.fetchConcept = () => Promise.resolve(makeConcept());
|
|
64
|
+
adapter.manifest = makeManifest();
|
|
65
|
+
|
|
66
|
+
mockFetch.mockImplementation((url: string) => {
|
|
67
|
+
if (url.endsWith('cross-ref-index.json')) {
|
|
68
|
+
return mockJsonResponse({ test: [] });
|
|
69
|
+
}
|
|
70
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await store.viewConcept('test', '1');
|
|
74
|
+
|
|
75
|
+
// Should load own edges (once) + domain nodes (once)
|
|
76
|
+
expect(edgeFetchCount).toBe(1);
|
|
77
|
+
expect(domainFetchCount).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('ensureEdgesForDataset', () => {
|
|
82
|
+
it('loads cross-referenced dataset edges via cross-ref-index', async () => {
|
|
83
|
+
const store = useVocabularyStore();
|
|
84
|
+
|
|
85
|
+
mockFetch.mockImplementation((url: string) => {
|
|
86
|
+
if (url.endsWith('cross-ref-index.json')) {
|
|
87
|
+
return mockJsonResponse({ 'viml-2022': ['viml-2013', 'viml-2000'] });
|
|
88
|
+
}
|
|
89
|
+
if (url.endsWith('edges.json')) {
|
|
90
|
+
return mockJsonResponse({ edges: [] });
|
|
91
|
+
}
|
|
92
|
+
if (url.endsWith('domain-nodes.json')) {
|
|
93
|
+
return mockJsonResponse({ domainNodes: [] });
|
|
94
|
+
}
|
|
95
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
let viml2013EdgeLoads = 0;
|
|
99
|
+
let viml2000EdgeLoads = 0;
|
|
100
|
+
let unrelatedEdgeLoads = 0;
|
|
101
|
+
|
|
102
|
+
const viml2022 = makeAdapterStub();
|
|
103
|
+
viml2022.fetchConcept = () => Promise.resolve(makeConcept());
|
|
104
|
+
viml2022.manifest = makeManifest({ id: 'viml-2022' });
|
|
105
|
+
|
|
106
|
+
const viml2013 = makeAdapterStub();
|
|
107
|
+
viml2013.loadEdgeIndex = () => { viml2013EdgeLoads++; return Promise.resolve([]); };
|
|
108
|
+
viml2013.loadDomainNodes = () => Promise.resolve([]);
|
|
109
|
+
viml2013.manifest = makeManifest({ id: 'viml-2013' });
|
|
110
|
+
|
|
111
|
+
const viml2000 = makeAdapterStub();
|
|
112
|
+
viml2000.loadEdgeIndex = () => { viml2000EdgeLoads++; return Promise.resolve([]); };
|
|
113
|
+
viml2000.loadDomainNodes = () => Promise.resolve([]);
|
|
114
|
+
viml2000.manifest = makeManifest({ id: 'viml-2000' });
|
|
115
|
+
|
|
116
|
+
const unrelated = makeAdapterStub();
|
|
117
|
+
unrelated.loadEdgeIndex = () => { unrelatedEdgeLoads++; return Promise.resolve([]); };
|
|
118
|
+
unrelated.loadDomainNodes = () => Promise.resolve([]);
|
|
119
|
+
unrelated.manifest = makeManifest({ id: 'unrelated' });
|
|
120
|
+
|
|
121
|
+
store.datasets.set('viml-2022', viml2022);
|
|
122
|
+
store.datasets.set('viml-2013', viml2013);
|
|
123
|
+
store.datasets.set('viml-2000', viml2000);
|
|
124
|
+
store.datasets.set('unrelated', unrelated);
|
|
125
|
+
|
|
126
|
+
await store.viewConcept('viml-2022', '1');
|
|
127
|
+
|
|
128
|
+
// viml-2013 and viml-2000 edges should be loaded (cross-referenced)
|
|
129
|
+
expect(viml2013EdgeLoads).toBe(1);
|
|
130
|
+
expect(viml2000EdgeLoads).toBe(1);
|
|
131
|
+
// unrelated dataset should NOT be loaded
|
|
132
|
+
expect(unrelatedEdgeLoads).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('skips datasets already loaded', async () => {
|
|
136
|
+
const store = useVocabularyStore();
|
|
137
|
+
|
|
138
|
+
// Pre-load edges for viml-2013
|
|
139
|
+
store.edgeStatus['viml-2013'] = { loaded: true, count: 100 };
|
|
140
|
+
|
|
141
|
+
let viml2013EdgeLoads = 0;
|
|
142
|
+
mockFetch.mockImplementation((url: string) => {
|
|
143
|
+
if (url.endsWith('cross-ref-index.json')) {
|
|
144
|
+
return mockJsonResponse({ 'viml-2022': ['viml-2013'] });
|
|
145
|
+
}
|
|
146
|
+
if (url.includes('viml-2013') && url.endsWith('edges.json')) {
|
|
147
|
+
viml2013EdgeLoads++;
|
|
148
|
+
return mockJsonResponse({ edges: [] });
|
|
149
|
+
}
|
|
150
|
+
if (url.endsWith('edges.json')) {
|
|
151
|
+
return mockJsonResponse({ edges: [] });
|
|
152
|
+
}
|
|
153
|
+
if (url.endsWith('domain-nodes.json')) {
|
|
154
|
+
return mockJsonResponse({ domainNodes: [] });
|
|
155
|
+
}
|
|
156
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const viml2022 = makeAdapterStub();
|
|
160
|
+
viml2022.fetchConcept = () => Promise.resolve(makeConcept());
|
|
161
|
+
viml2022.manifest = makeManifest({ id: 'viml-2022' });
|
|
162
|
+
|
|
163
|
+
const viml2013 = makeAdapterStub();
|
|
164
|
+
viml2013.loadEdgeIndex = () => { viml2013EdgeLoads++; return Promise.resolve([]); };
|
|
165
|
+
viml2013.loadDomainNodes = () => Promise.resolve([]);
|
|
166
|
+
viml2013.manifest = makeManifest({ id: 'viml-2013' });
|
|
167
|
+
|
|
168
|
+
store.datasets.set('viml-2022', viml2022);
|
|
169
|
+
store.datasets.set('viml-2013', viml2013);
|
|
170
|
+
store.manifests.set('viml-2022', makeManifest({ id: 'viml-2022' }));
|
|
171
|
+
store.manifests.set('viml-2013', makeManifest({ id: 'viml-2013' }));
|
|
172
|
+
|
|
173
|
+
await store.viewConcept('viml-2022', '1');
|
|
174
|
+
|
|
175
|
+
// viml-2013 edges should NOT be re-loaded
|
|
176
|
+
expect(viml2013EdgeLoads).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('searchAcrossDatasets — two-pass', () => {
|
|
181
|
+
it('returns results from loaded data without loading chunks', async () => {
|
|
182
|
+
const store = useVocabularyStore();
|
|
183
|
+
store.initialized = true;
|
|
184
|
+
|
|
185
|
+
let chunksLoaded = false;
|
|
186
|
+
|
|
187
|
+
const adapter = makeAdapterStub({
|
|
188
|
+
concepts: Array.from({ length: 25 }, (_, i) => ({
|
|
189
|
+
id: `${i + 1}`,
|
|
190
|
+
designations: { eng: `concept ${i + 1}` },
|
|
191
|
+
eng: `concept ${i + 1}`,
|
|
192
|
+
status: 'valid',
|
|
193
|
+
})),
|
|
194
|
+
});
|
|
195
|
+
adapter.manifest = makeManifest();
|
|
196
|
+
adapter.index = { concepts: adapter.getConcepts(), conceptCount: 25, registerId: 'test', schemaVersion: '1.0', chunkSize: 500, chunks: [] };
|
|
197
|
+
adapter.search = () => Array.from({ length: 25 }, (_, i) =>
|
|
198
|
+
makeSearchHit({ conceptId: `${i + 1}`, designation: `concept ${i + 1}` })
|
|
199
|
+
);
|
|
200
|
+
adapter.ensureAllChunksLoaded = () => {
|
|
201
|
+
chunksLoaded = true;
|
|
202
|
+
return Promise.resolve();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
store.datasets.set('test', adapter);
|
|
206
|
+
|
|
207
|
+
const results = await store.searchAcrossDatasets('concept');
|
|
208
|
+
|
|
209
|
+
expect(results.length).toBe(25);
|
|
210
|
+
// With ≥20 results, chunks should NOT be loaded
|
|
211
|
+
expect(chunksLoaded).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('loads chunks for adapters with zero index hits when total is under threshold', async () => {
|
|
215
|
+
const store = useVocabularyStore();
|
|
216
|
+
store.initialized = true;
|
|
217
|
+
|
|
218
|
+
let adapter1ChunksLoaded = false;
|
|
219
|
+
let adapter2ChunksLoaded = false;
|
|
220
|
+
|
|
221
|
+
// Adapter 1: has a few results in index (5 hits)
|
|
222
|
+
const adapter1 = makeAdapterStub({
|
|
223
|
+
concepts: Array.from({ length: 5 }, (_, i) => ({
|
|
224
|
+
id: `${i + 1}`,
|
|
225
|
+
designations: { eng: `item ${i + 1}` },
|
|
226
|
+
eng: `item ${i + 1}`,
|
|
227
|
+
status: 'valid',
|
|
228
|
+
})),
|
|
229
|
+
});
|
|
230
|
+
adapter1.manifest = makeManifest({ id: 'ds1' });
|
|
231
|
+
adapter1.registerId = 'ds1';
|
|
232
|
+
adapter1.index = { concepts: adapter1.getConcepts(), conceptCount: 5, registerId: 'ds1', schemaVersion: '1.0', chunkSize: 500, chunks: [] };
|
|
233
|
+
adapter1.search = () => Array.from({ length: 5 }, (_, i) =>
|
|
234
|
+
makeSearchHit({ registerId: 'ds1', conceptId: `${i + 1}`, designation: `item ${i + 1}` })
|
|
235
|
+
);
|
|
236
|
+
adapter1.ensureAllChunksLoaded = () => {
|
|
237
|
+
adapter1ChunksLoaded = true;
|
|
238
|
+
return Promise.resolve();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Adapter 2: has no results in index but will find some after chunk loading
|
|
242
|
+
const adapter2 = makeAdapterStub();
|
|
243
|
+
adapter2.manifest = makeManifest({ id: 'ds2' });
|
|
244
|
+
adapter2.registerId = 'ds2';
|
|
245
|
+
adapter2.index = { concepts: [], conceptCount: 0, registerId: 'ds2', schemaVersion: '1.0', chunkSize: 500, chunks: [] };
|
|
246
|
+
adapter2.search = () => [];
|
|
247
|
+
let adapter2CalledAfterChunkLoad = false;
|
|
248
|
+
adapter2.ensureAllChunksLoaded = () => {
|
|
249
|
+
adapter2ChunksLoaded = true;
|
|
250
|
+
// After chunks load, search returns results
|
|
251
|
+
adapter2.search = () => [makeSearchHit({ registerId: 'ds2', conceptId: '100', designation: 'item hidden' })];
|
|
252
|
+
return Promise.resolve();
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
store.datasets.set('ds1', adapter1);
|
|
256
|
+
store.datasets.set('ds2', adapter2);
|
|
257
|
+
|
|
258
|
+
const results = await store.searchAcrossDatasets('item');
|
|
259
|
+
|
|
260
|
+
expect(results.length).toBe(6); // 5 from ds1 index + 1 from ds2 chunks
|
|
261
|
+
// Adapter 1 should NOT have loaded chunks (it had index hits)
|
|
262
|
+
expect(adapter1ChunksLoaded).toBe(false);
|
|
263
|
+
// Adapter 2 should have loaded chunks (it had 0 index hits, total < 20)
|
|
264
|
+
expect(adapter2ChunksLoaded).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('does not load chunks when adapter search has 0 hits from index', async () => {
|
|
268
|
+
const store = useVocabularyStore();
|
|
269
|
+
store.initialized = true;
|
|
270
|
+
|
|
271
|
+
let chunksLoaded = false;
|
|
272
|
+
|
|
273
|
+
const adapter = makeAdapterStub();
|
|
274
|
+
adapter.manifest = makeManifest();
|
|
275
|
+
adapter.index = { concepts: [], conceptCount: 0, registerId: 'test', schemaVersion: '1.0', chunkSize: 500, chunks: [] };
|
|
276
|
+
adapter.search = () => [];
|
|
277
|
+
adapter.ensureAllChunksLoaded = () => {
|
|
278
|
+
chunksLoaded = true;
|
|
279
|
+
return Promise.resolve();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
store.datasets.set('test', adapter);
|
|
283
|
+
|
|
284
|
+
const results = await store.searchAcrossDatasets('nonexistent');
|
|
285
|
+
|
|
286
|
+
expect(results.length).toBe(0);
|
|
287
|
+
// Should still try loading chunks for the adapter that found nothing
|
|
288
|
+
expect(chunksLoaded).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
GraphEdge,
|
|
8
8
|
GraphNode,
|
|
9
9
|
SectionNode,
|
|
10
|
+
DatasetSummary,
|
|
10
11
|
} from './types';
|
|
11
12
|
import type { Concept, LocalizedConcept, Designation } from 'glossarist';
|
|
12
13
|
import { conceptFromJson, conceptUri } from './model-bridge';
|
|
@@ -37,8 +38,10 @@ export class DatasetAdapter {
|
|
|
37
38
|
private baseUrl: string;
|
|
38
39
|
manifest: Manifest | null = null;
|
|
39
40
|
index: ConceptIndex | null = null;
|
|
41
|
+
private manifestComplete = false;
|
|
40
42
|
|
|
41
43
|
private conceptCache = new Map<string, Concept>();
|
|
44
|
+
private static MAX_CACHE = 100;
|
|
42
45
|
private summaryMap = new Map<string, ConceptSummary>();
|
|
43
46
|
private loadedChunks = new Set<number>();
|
|
44
47
|
private indexMeta: { conceptCount: number; chunkSize: number; chunks: { file: string; count: number }[] } | null = null;
|
|
@@ -48,10 +51,37 @@ export class DatasetAdapter {
|
|
|
48
51
|
this.baseUrl = baseUrl;
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
setSummaryManifest(summary: DatasetSummary): void {
|
|
55
|
+
this.manifest = {
|
|
56
|
+
id: this.registerId,
|
|
57
|
+
datasetUri: '',
|
|
58
|
+
title: summary.title,
|
|
59
|
+
description: summary.description,
|
|
60
|
+
owner: summary.owner,
|
|
61
|
+
baseUrl: this.baseUrl,
|
|
62
|
+
languages: summary.languages,
|
|
63
|
+
conceptCount: summary.conceptCount,
|
|
64
|
+
conceptUrlTemplate: '',
|
|
65
|
+
indexUrl: '',
|
|
66
|
+
contextUrl: '',
|
|
67
|
+
uriBase: '',
|
|
68
|
+
status: '',
|
|
69
|
+
schemaVersion: '',
|
|
70
|
+
tags: summary.tags,
|
|
71
|
+
lastUpdated: '',
|
|
72
|
+
sourceRepo: '',
|
|
73
|
+
chunkSize: 1000,
|
|
74
|
+
color: summary.color,
|
|
75
|
+
};
|
|
76
|
+
this.manifestComplete = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
async loadManifest(): Promise<Manifest> {
|
|
80
|
+
if (this.manifestComplete && this.manifest) return this.manifest;
|
|
52
81
|
const resp = await fetch(`${this.baseUrl}/manifest.json`);
|
|
53
82
|
if (!resp.ok) throw new Error(`Failed to load manifest for ${this.registerId}: ${resp.status}`);
|
|
54
83
|
this.manifest = (await resp.json()) as Manifest;
|
|
84
|
+
this.manifestComplete = true;
|
|
55
85
|
return this.manifest;
|
|
56
86
|
}
|
|
57
87
|
|
|
@@ -79,6 +109,7 @@ export class DatasetAdapter {
|
|
|
79
109
|
designations: c.designations || {},
|
|
80
110
|
eng: c.eng || c.designations?.eng || Object.values(c.designations || {})[0] || '',
|
|
81
111
|
status: c.status,
|
|
112
|
+
groups: c.groups || [],
|
|
82
113
|
}));
|
|
83
114
|
|
|
84
115
|
return {
|
|
@@ -166,6 +197,7 @@ export class DatasetAdapter {
|
|
|
166
197
|
designations,
|
|
167
198
|
eng: designations.eng || Object.values(designations)[0] || '',
|
|
168
199
|
status: entry.status,
|
|
200
|
+
groups: entry.groups || [],
|
|
169
201
|
};
|
|
170
202
|
this.index!.concepts[startPos + i] = summary;
|
|
171
203
|
this.summaryMap.set(entry.id, summary);
|
|
@@ -207,13 +239,21 @@ export class DatasetAdapter {
|
|
|
207
239
|
|
|
208
240
|
async fetchConcept(conceptId: string): Promise<Concept> {
|
|
209
241
|
const cached = this.conceptCache.get(conceptId);
|
|
210
|
-
if (cached)
|
|
242
|
+
if (cached) {
|
|
243
|
+
this.conceptCache.delete(conceptId);
|
|
244
|
+
this.conceptCache.set(conceptId, cached);
|
|
245
|
+
return cached;
|
|
246
|
+
}
|
|
211
247
|
|
|
212
248
|
const resp = await fetch(`${this.baseUrl}/concepts/${conceptId}.json`);
|
|
213
249
|
if (!resp.ok) throw new Error(`Concept ${conceptId} not found in ${this.registerId}`);
|
|
214
250
|
const json = await resp.json();
|
|
215
251
|
const concept = conceptFromJson(json);
|
|
216
252
|
this.conceptCache.set(conceptId, concept);
|
|
253
|
+
if (this.conceptCache.size > DatasetAdapter.MAX_CACHE) {
|
|
254
|
+
const oldest = this.conceptCache.keys().next().value;
|
|
255
|
+
if (oldest !== undefined) this.conceptCache.delete(oldest);
|
|
256
|
+
}
|
|
217
257
|
return concept;
|
|
218
258
|
}
|
|
219
259
|
|