@glossarist/concept-browser 0.7.21 → 0.7.23

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.
Files changed (39) hide show
  1. package/index.html +2 -1
  2. package/package.json +2 -2
  3. package/scripts/build-edges.js +50 -5
  4. package/scripts/generate-data.mjs +33 -6
  5. package/src/App.vue +10 -12
  6. package/src/__tests__/concept-view.test.ts +7 -1
  7. package/src/__tests__/dataset-adapter.test.ts +87 -0
  8. package/src/__tests__/dataset-view.test.ts +1 -0
  9. package/src/__tests__/factory-lazy.test.ts +183 -0
  10. package/src/__tests__/graph-engine-fixes.test.ts +104 -0
  11. package/src/__tests__/ontology-registry.test.ts +1 -1
  12. package/src/__tests__/performance-v2.test.ts +77 -0
  13. package/src/__tests__/performance.test.ts +95 -0
  14. package/src/__tests__/relationship-categories.test.ts +3 -3
  15. package/src/__tests__/search-utils.test.ts +59 -0
  16. package/src/__tests__/test-helpers.ts +4 -0
  17. package/src/__tests__/utils-barrel.test.ts +15 -0
  18. package/src/__tests__/vocabulary-layered.test.ts +291 -0
  19. package/src/adapters/DatasetAdapter.ts +41 -1
  20. package/src/adapters/factory.ts +35 -4
  21. package/src/adapters/ontology-registry.ts +1 -1
  22. package/src/adapters/types.ts +12 -0
  23. package/src/components/AppSidebar.vue +17 -343
  24. package/src/components/ConceptDetail.vue +124 -55
  25. package/src/components/GraphPanel.vue +14 -6
  26. package/src/components/OntologySidebarSection.vue +338 -0
  27. package/src/config/use-site-config.ts +20 -9
  28. package/src/data/taxonomies.json +246 -18
  29. package/src/directives/v-math.ts +2 -3
  30. package/src/graph/GraphEngine.ts +22 -5
  31. package/src/i18n/index.ts +1 -1
  32. package/src/stores/vocabulary.ts +65 -105
  33. package/src/utils/index.ts +1 -0
  34. package/src/utils/relationship-categories.ts +15 -6
  35. package/src/utils/search.ts +15 -0
  36. package/src/views/ConceptView.vue +0 -2
  37. package/src/views/DatasetView.vue +64 -39
  38. package/src/views/HomeView.vue +0 -1
  39. 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(14);
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);
@@ -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
+ });
@@ -52,11 +52,11 @@ describe('categorizeRelationship', () => {
52
52
 
53
53
  describe('relationshipLabel', () => {
54
54
  it('formats snake_case as title case', () => {
55
- expect(relationshipLabel('broader_generic')).toBe('Broader Generic');
56
- expect(relationshipLabel('related_concept')).toBe('Related Concept');
55
+ expect(relationshipLabel('broader_generic')).toBe('broader (generic)');
56
+ expect(relationshipLabel('related_concept')).toBe('related concept');
57
57
  });
58
58
 
59
59
  it('handles single word', () => {
60
- expect(relationshipLabel('broader')).toBe('Broader');
60
+ expect(relationshipLabel('broader')).toBe('broader');
61
61
  });
62
62
  });
@@ -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) return 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