@glossarist/concept-browser 0.7.22 → 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 (38) hide show
  1. package/index.html +2 -1
  2. package/package.json +1 -1
  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 +4 -4
  12. package/src/__tests__/performance-v2.test.ts +77 -0
  13. package/src/__tests__/performance.test.ts +95 -0
  14. package/src/__tests__/search-utils.test.ts +59 -0
  15. package/src/__tests__/test-helpers.ts +4 -0
  16. package/src/__tests__/utils-barrel.test.ts +15 -0
  17. package/src/__tests__/vocabulary-layered.test.ts +291 -0
  18. package/src/adapters/DatasetAdapter.ts +41 -1
  19. package/src/adapters/factory.ts +35 -4
  20. package/src/adapters/ontology-registry.ts +1 -1
  21. package/src/adapters/types.ts +12 -0
  22. package/src/components/AppSidebar.vue +17 -343
  23. package/src/components/ConceptDetail.vue +124 -55
  24. package/src/components/GraphPanel.vue +14 -6
  25. package/src/components/OntologySidebarSection.vue +338 -0
  26. package/src/config/use-site-config.ts +20 -9
  27. package/src/data/taxonomies.json +12 -6
  28. package/src/directives/v-math.ts +2 -3
  29. package/src/graph/GraphEngine.ts +22 -5
  30. package/src/i18n/index.ts +1 -1
  31. package/src/stores/vocabulary.ts +65 -105
  32. package/src/utils/index.ts +1 -0
  33. package/src/utils/relationship-categories.ts +3 -2
  34. package/src/utils/search.ts +15 -0
  35. package/src/views/ConceptView.vue +0 -2
  36. package/src/views/DatasetView.vue +64 -39
  37. package/src/views/HomeView.vue +0 -1
  38. package/vite.config.ts +94 -6
package/index.html CHANGED
@@ -5,7 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="preconnect" href="https://fonts.googleapis.com">
7
7
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
8
- <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" onload="this.rel='stylesheet'">
9
+ <noscript><link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Serif+Display:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"></noscript>
9
10
  <title>Concept Browser</title>
10
11
  </head>
11
12
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.22",
3
+ "version": "0.7.23",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -129,7 +129,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
129
129
  const conceptsDir = join(datasetDir, 'concepts');
130
130
  if (!existsSync(conceptsDir)) {
131
131
  console.log(` Skipping ${registerId}: no concepts directory`);
132
- return;
132
+ return [];
133
133
  }
134
134
 
135
135
  const files = readdirSync(conceptsDir).filter(f => f.endsWith('.json'));
@@ -177,7 +177,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
177
177
  };
178
178
 
179
179
  const outputPath = join(datasetDir, 'edges.json');
180
- writeFileSync(outputPath, JSON.stringify(output, null, 2));
180
+ writeFileSync(outputPath, JSON.stringify(output));
181
181
  console.log(` Written ${deduped.length} edges to edges.json (${(JSON.stringify(output).length / 1024).toFixed(1)} KB)`);
182
182
 
183
183
  // Build domain-nodes.json from manifest sections (authoritative source)
@@ -212,7 +212,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
212
212
 
213
213
  const domainOutput = { registerId, domainNodes };
214
214
  const domainPath = join(datasetDir, 'domain-nodes.json');
215
- writeFileSync(domainPath, JSON.stringify(domainOutput, null, 2));
215
+ writeFileSync(domainPath, JSON.stringify(domainOutput));
216
216
  console.log(` Written ${domainNodes.length} section-based domain nodes to domain-nodes.json`);
217
217
  } else {
218
218
  // Fallback: derive domain nodes from concept edges (legacy behavior)
@@ -237,9 +237,11 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
237
237
 
238
238
  const domainOutput = { registerId, domainNodes };
239
239
  const domainPath = join(datasetDir, 'domain-nodes.json');
240
- writeFileSync(domainPath, JSON.stringify(domainOutput, null, 2));
240
+ writeFileSync(domainPath, JSON.stringify(domainOutput));
241
241
  console.log(` Written ${domainNodes.length} edge-derived domain nodes to domain-nodes.json`);
242
242
  }
243
+
244
+ return deduped;
243
245
  }
244
246
 
245
247
  // Main
@@ -275,17 +277,60 @@ for (const ds of datasets) {
275
277
  }
276
278
  console.log(`URN resolution map: ${[...urnMap.entries()].map(([k,v]) => `${k}→${v}`).join(', ')}\n`);
277
279
 
280
+ const allDatasetEdges = new Map();
281
+
278
282
  for (const ds of datasets) {
279
283
  const manifest = manifestCache.get(ds);
280
284
  if (!manifest) continue;
281
285
  try {
282
286
  console.log(`${manifest.title} (${ds}):`);
283
287
  const uriBase = manifest.uriBase || 'https://glossarist.org';
284
- buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
288
+ const edges = buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
289
+ allDatasetEdges.set(ds, edges);
285
290
  } catch (e) {
286
291
  console.error(`Error reading manifest for ${ds}: ${e.message}`);
287
292
  }
288
293
  console.log();
289
294
  }
290
295
 
296
+ // Build cross-reference index: for each dataset, which other datasets'
297
+ // edges.json contains edges targeting that dataset's URIs.
298
+ const datasetUriPrefixes = new Map();
299
+ for (const [ds, manifest] of manifestCache) {
300
+ const uriBase = manifest.uriBase || 'https://glossarist.org';
301
+ datasetUriPrefixes.set(ds, `${uriBase}/${ds}/`);
302
+ }
303
+
304
+ const crossRefIndex = {};
305
+ for (const ds of datasets) {
306
+ crossRefIndex[ds] = [];
307
+ }
308
+
309
+ for (const [sourceDs, edges] of allDatasetEdges) {
310
+ const targets = new Set();
311
+ for (const edge of edges) {
312
+ for (const [targetDs, prefix] of datasetUriPrefixes) {
313
+ if (targetDs !== sourceDs && edge.target.startsWith(prefix)) {
314
+ targets.add(targetDs);
315
+ }
316
+ }
317
+ // Also check source URIs targeting other datasets
318
+ for (const [targetDs, prefix] of datasetUriPrefixes) {
319
+ if (targetDs !== sourceDs && edge.source.startsWith(prefix)) {
320
+ targets.add(targetDs);
321
+ }
322
+ }
323
+ }
324
+ for (const targetDs of targets) {
325
+ if (!crossRefIndex[targetDs].includes(sourceDs)) {
326
+ crossRefIndex[targetDs].push(sourceDs);
327
+ }
328
+ }
329
+ }
330
+
331
+ const crossRefPath = join(DATA_DIR, 'cross-ref-index.json');
332
+ writeFileSync(crossRefPath, JSON.stringify(crossRefIndex));
333
+ const refCount = Object.values(crossRefIndex).reduce((sum, arr) => sum + arr.length, 0);
334
+ console.log(`Written cross-ref-index.json (${refCount} cross-references across ${datasets.length} datasets)`);
335
+
291
336
  console.log('Done.');
@@ -41,10 +41,17 @@ function loadConceptFile(filePath) {
41
41
  if (mc.date_accepted) result._dateAccepted = mc.date_accepted;
42
42
 
43
43
  for (const doc of docs.slice(1)) {
44
- if (!doc || !doc.data || !doc.data.language_code) continue;
45
- const lang = doc.data.language_code;
46
- const lcData = { ...doc.data };
44
+ if (!doc) continue;
45
+ const lang = doc.data?.language_code || doc.language_code;
46
+ if (!lang) continue;
47
+ const lcData = { ...(doc.data || {}) };
47
48
  delete lcData.language_code;
49
+ // Merge top-level fields (terms, definition, notes, etc.) into lcData
50
+ for (const key of ['terms', 'definition', 'notes', 'examples', 'sources', 'dates', 'domain', 'references', 'entry_status', 'classification', 'review_type', 'review_date', 'review_decision_date', 'review_decision_event', 'review_status', 'review_decision', 'review_decision_notes', 'lineage_source_similarity', 'release', 'script', 'system']) {
51
+ if (doc[key] !== undefined && lcData[key] === undefined) {
52
+ lcData[key] = doc[key];
53
+ }
54
+ }
48
55
  result[lang] = lcData;
49
56
  }
50
57
  return result;
@@ -55,7 +62,7 @@ function loadConceptFile(filePath) {
55
62
 
56
63
  function writeJson(filePath, data) {
57
64
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
58
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
65
+ fs.writeFileSync(filePath, JSON.stringify(data));
59
66
  }
60
67
 
61
68
  function termToDesignation(term) {
@@ -381,6 +388,13 @@ function getPrimaryDesignation(conceptYaml) {
381
388
 
382
389
  function getGroups(conceptYaml) {
383
390
  if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
391
+ // Derive groups from domains (e.g. section-based grouping in G18)
392
+ if (conceptYaml._domains) {
393
+ const sectionIds = conceptYaml._domains
394
+ .filter(d => d.ref_type === 'section' && d.concept_id)
395
+ .map(d => d.concept_id.replace(/^section-/, ''));
396
+ if (sectionIds.length) return sectionIds;
397
+ }
384
398
  const termid = String(conceptYaml.termid);
385
399
  if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
386
400
  if (/^\d+\.\d+\.\d+/.test(termid)) {
@@ -466,7 +480,7 @@ function conceptJsonToSkosJsonLd(concept) {
466
480
  if (Object.keys(definitions).length) doc['skos:definition'] = definitions;
467
481
  if (Object.keys(scopeNotes).length) doc['skos:scopeNote'] = scopeNotes;
468
482
 
469
- return JSON.stringify(doc, null, 2);
483
+ return JSON.stringify(doc);
470
484
  }
471
485
 
472
486
  function escapeXml(s) {
@@ -670,6 +684,7 @@ function processDataset(dir, register, opts) {
670
684
  designations: c.designations,
671
685
  eng: c.designations.eng || Object.values(c.designations)[0] || '',
672
686
  status: c.status,
687
+ groups: c.groups || [],
673
688
  }));
674
689
 
675
690
  // Strip HTML from index summary for text display
@@ -878,7 +893,19 @@ for (let i = 0; i < config.datasets.length; i++) {
878
893
  hasBibliography: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'bibliography.yaml')),
879
894
  hasImages: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'images')),
880
895
  });
881
- registry.push({ id: ds.id, manifestUrl: `/data/${ds.id}/manifest.json` });
896
+ registry.push({
897
+ id: ds.id,
898
+ manifestUrl: `/data/${ds.id}/manifest.json`,
899
+ summary: {
900
+ title: resolvedTitle,
901
+ description: resolvedDescription,
902
+ conceptCount: counts[ds.id] || 0,
903
+ languages: dsLanguages,
904
+ owner: ds.owner || reg?.owner || '',
905
+ tags: ds.tags || reg?.tags || [],
906
+ color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
907
+ },
908
+ });
882
909
  }
883
910
  writeJson(path.join(PUBLIC, 'datasets.json'), registry);
884
911
 
package/src/App.vue CHANGED
@@ -12,30 +12,28 @@ const { loadConfig, config } = useSiteConfig();
12
12
  const { initLocale } = useI18n();
13
13
  const appReady = ref(false);
14
14
  const showScrollTop = ref(false);
15
- let mainEl: HTMLElement | null = null;
15
+ const mainRef = ref<HTMLElement | null>(null);
16
16
 
17
17
  function onMainScroll() {
18
- showScrollTop.value = (mainEl?.scrollTop ?? 0) > 400;
18
+ showScrollTop.value = (mainRef.value?.scrollTop ?? 0) > 400;
19
19
  }
20
20
 
21
21
  function scrollToTop() {
22
- mainEl?.scrollTo({ top: 0, behavior: 'smooth' });
22
+ mainRef.value?.scrollTo({ top: 0, behavior: 'smooth' });
23
23
  }
24
24
 
25
25
  onMounted(async () => {
26
- const [, cfg] = await Promise.all([store.discoverDatasets(), loadConfig()]);
27
- if (cfg?.title) {
28
- document.title = cfg.title;
26
+ await Promise.all([store.discoverDatasets(), loadConfig()]);
27
+ if (config.value?.title) {
28
+ document.title = config.value.title;
29
29
  }
30
- initLocale(cfg?.defaults?.language);
30
+ initLocale(config.value?.defaults?.language);
31
31
  appReady.value = true;
32
- // Watch scroll on main content area
33
- mainEl = document.querySelector('main');
34
- mainEl?.addEventListener('scroll', onMainScroll, { passive: true });
32
+ mainRef.value?.addEventListener('scroll', onMainScroll, { passive: true });
35
33
  });
36
34
 
37
35
  onUnmounted(() => {
38
- mainEl?.removeEventListener('scroll', onMainScroll);
36
+ mainRef.value?.removeEventListener('scroll', onMainScroll);
39
37
  });
40
38
  </script>
41
39
 
@@ -44,7 +42,7 @@ onUnmounted(() => {
44
42
  <AppHeader />
45
43
  <div class="flex flex-1 overflow-hidden">
46
44
  <AppSidebar />
47
- <main class="flex-1 overflow-y-auto bg-surface flex flex-col">
45
+ <main ref="mainRef" class="flex-1 overflow-y-auto bg-surface flex flex-col">
48
46
  <div v-if="!appReady" class="flex items-center justify-center h-[70vh]">
49
47
  <div class="w-full max-w-md px-6 space-y-6">
50
48
  <!-- Title skeleton -->
@@ -1,10 +1,14 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { mount, flushPromises } from '@vue/test-utils';
3
3
  import ConceptView from '../views/ConceptView.vue';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { conceptFromJson } from '../adapters/model-bridge';
6
6
  import { createTestRouter, setupPinia, makeManifest, makeAdapterStub } from './test-helpers';
7
7
 
8
+ // Mock fetch for cross-ref-index.json requests from ensureEdgesForDataset
9
+ const mockFetch = vi.fn();
10
+ global.fetch = mockFetch;
11
+
8
12
  describe('ConceptView', () => {
9
13
  let pinia: ReturnType<typeof setupPinia>;
10
14
  let router: Awaited<ReturnType<typeof createTestRouter>>;
@@ -12,6 +16,8 @@ describe('ConceptView', () => {
12
16
  beforeEach(async () => {
13
17
  pinia = setupPinia();
14
18
  router = await createTestRouter('dataset', '/');
19
+ mockFetch.mockReset();
20
+ mockFetch.mockResolvedValue({ ok: false, status: 404 } as Response);
15
21
  const store = useVocabularyStore();
16
22
  store.manifests.set('test', makeManifest());
17
23
  store.datasets.set('test', makeAdapterStub());
@@ -50,6 +50,18 @@ describe('DatasetAdapter', () => {
50
50
 
51
51
  await expect(adapter.loadManifest()).rejects.toThrow('Failed to load manifest');
52
52
  });
53
+
54
+ it('returns cached manifest without fetching again', async () => {
55
+ const manifest = { id: 'test', title: 'Cached', chunkSize: 500 };
56
+ mockFetch.mockReturnValue(mockJsonResponse(manifest));
57
+ await adapter.loadManifest();
58
+ expect(mockFetch).toHaveBeenCalledTimes(1);
59
+
60
+ mockFetch.mockReset();
61
+ const result = await adapter.loadManifest();
62
+ expect(result.title).toBe('Cached');
63
+ expect(mockFetch).not.toHaveBeenCalled();
64
+ });
53
65
  });
54
66
 
55
67
  describe('loadIndex', () => {
@@ -513,4 +525,79 @@ describe('DatasetAdapter', () => {
513
525
  expect(adapter.getLanguages()).toEqual([]);
514
526
  });
515
527
  });
528
+
529
+ describe('setSummaryManifest', () => {
530
+ it('creates a partial manifest from summary data', () => {
531
+ adapter.setSummaryManifest({
532
+ title: 'Test Summary',
533
+ description: 'Summary description',
534
+ conceptCount: 42,
535
+ languages: ['eng', 'fra'],
536
+ owner: 'Test Owner',
537
+ tags: ['tag1', 'tag2'],
538
+ color: '#ff0000',
539
+ });
540
+
541
+ expect(adapter.manifest).not.toBeNull();
542
+ expect(adapter.manifest!.title).toBe('Test Summary');
543
+ expect(adapter.manifest!.description).toBe('Summary description');
544
+ expect(adapter.manifest!.conceptCount).toBe(42);
545
+ expect(adapter.manifest!.languages).toEqual(['eng', 'fra']);
546
+ expect(adapter.manifest!.owner).toBe('Test Owner');
547
+ expect(adapter.manifest!.tags).toEqual(['tag1', 'tag2']);
548
+ expect(adapter.manifest!.color).toBe('#ff0000');
549
+ expect(adapter.manifest!.id).toBe('test');
550
+ expect(adapter.manifest!.baseUrl).toBe('/data/test');
551
+ });
552
+
553
+ it('allows loadManifest to fetch the full manifest', async () => {
554
+ adapter.setSummaryManifest({
555
+ title: 'Summary',
556
+ description: '',
557
+ conceptCount: 10,
558
+ languages: ['eng'],
559
+ owner: '',
560
+ tags: [],
561
+ });
562
+
563
+ const fullManifest = {
564
+ id: 'test',
565
+ title: 'Full Dataset',
566
+ description: 'Full description',
567
+ owner: 'Test',
568
+ baseUrl: '/data/test',
569
+ languages: ['eng'],
570
+ conceptCount: 42,
571
+ chunkSize: 500,
572
+ sections: [{ id: 's1', names: { eng: 'Section 1' } }],
573
+ };
574
+ mockFetch.mockReturnValue(mockJsonResponse(fullManifest));
575
+
576
+ const result = await adapter.loadManifest();
577
+ expect(result.title).toBe('Full Dataset');
578
+ expect(result.conceptCount).toBe(42);
579
+ expect(mockFetch).toHaveBeenCalledWith('/data/test/manifest.json');
580
+ });
581
+
582
+ it('uses cached full manifest after first load', async () => {
583
+ adapter.setSummaryManifest({
584
+ title: 'Summary',
585
+ description: '',
586
+ conceptCount: 10,
587
+ languages: ['eng'],
588
+ owner: '',
589
+ tags: [],
590
+ });
591
+
592
+ const fullManifest = { id: 'test', title: 'Full', chunkSize: 500 };
593
+ mockFetch.mockReturnValue(mockJsonResponse(fullManifest));
594
+
595
+ await adapter.loadManifest();
596
+ expect(mockFetch).toHaveBeenCalledTimes(1);
597
+
598
+ mockFetch.mockReset();
599
+ await adapter.loadManifest();
600
+ expect(mockFetch).not.toHaveBeenCalled();
601
+ });
602
+ });
516
603
  });
@@ -52,6 +52,7 @@ function makeAdapter(concepts: ConceptSummary[] = []) {
52
52
  ensureChunksForRange: async () => {},
53
53
  ensureAllChunksLoaded: async () => {},
54
54
  getAdjacentConcepts: () => ({ prev: null, next: null }),
55
+ getSectionTree: () => [],
55
56
  } as any;
56
57
  }
57
58
 
@@ -0,0 +1,183 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { getFactory, resetFactory } from '../adapters/factory';
3
+ import { setupPinia } from './test-helpers';
4
+
5
+ const mockFetch = vi.fn();
6
+ global.fetch = mockFetch;
7
+
8
+ function mockJsonResponse(data: any) {
9
+ return Promise.resolve({
10
+ ok: true,
11
+ status: 200,
12
+ json: () => Promise.resolve(data),
13
+ } as Response);
14
+ }
15
+
16
+ describe('AdapterFactory — lazy discovery', () => {
17
+ let pinia: ReturnType<typeof setupPinia>;
18
+
19
+ beforeEach(() => {
20
+ pinia = setupPinia();
21
+ resetFactory();
22
+ mockFetch.mockReset();
23
+ });
24
+
25
+ afterEach(() => {
26
+ resetFactory();
27
+ });
28
+
29
+ it('skips manifest fetch when summary is present', async () => {
30
+ const factory = getFactory();
31
+
32
+ mockFetch.mockImplementation((url: string) => {
33
+ if (url.endsWith('datasets.json')) {
34
+ return mockJsonResponse([
35
+ {
36
+ id: 'ds1',
37
+ manifestUrl: '/data/ds1/manifest.json',
38
+ summary: {
39
+ title: 'Dataset 1',
40
+ description: 'First dataset',
41
+ conceptCount: 100,
42
+ languages: ['eng'],
43
+ owner: 'Test',
44
+ tags: [],
45
+ color: '#ff0000',
46
+ },
47
+ },
48
+ ]);
49
+ }
50
+ return Promise.resolve({ ok: false, status: 404 } as Response);
51
+ });
52
+
53
+ const adapters = await factory.discoverDatasets('/datasets.json');
54
+
55
+ expect(adapters.length).toBe(1);
56
+ expect(adapters[0].manifest).not.toBeNull();
57
+ expect(adapters[0].manifest!.title).toBe('Dataset 1');
58
+ expect(adapters[0].manifest!.conceptCount).toBe(100);
59
+ // Should NOT fetch manifest.json — only datasets.json
60
+ expect(mockFetch).toHaveBeenCalledTimes(1);
61
+ expect(mockFetch).toHaveBeenCalledWith('/datasets.json');
62
+ });
63
+
64
+ it('fetches manifests when no summary is present', async () => {
65
+ const factory = getFactory();
66
+
67
+ mockFetch.mockImplementation((url: string) => {
68
+ if (url.endsWith('datasets.json')) {
69
+ return mockJsonResponse([
70
+ { id: 'ds1', manifestUrl: '/data/ds1/manifest.json' },
71
+ ]);
72
+ }
73
+ if (url.endsWith('manifest.json')) {
74
+ return mockJsonResponse({
75
+ id: 'ds1',
76
+ title: 'Full Dataset',
77
+ description: 'Full description',
78
+ owner: 'Test',
79
+ baseUrl: '/data/ds1',
80
+ languages: ['eng'],
81
+ conceptCount: 50,
82
+ chunkSize: 500,
83
+ });
84
+ }
85
+ return Promise.resolve({ ok: false, status: 404 } as Response);
86
+ });
87
+
88
+ const adapters = await factory.discoverDatasets('/datasets.json');
89
+
90
+ expect(adapters.length).toBe(1);
91
+ expect(adapters[0].manifest!.title).toBe('Full Dataset');
92
+ // Should fetch both datasets.json and manifest.json
93
+ expect(mockFetch).toHaveBeenCalledTimes(2);
94
+ });
95
+
96
+ it('loads full manifest in loadDataset after summary discovery', async () => {
97
+ const factory = getFactory();
98
+
99
+ let callCount = 0;
100
+ mockFetch.mockImplementation((url: string) => {
101
+ if (url.endsWith('datasets.json')) {
102
+ return mockJsonResponse([
103
+ {
104
+ id: 'ds1',
105
+ manifestUrl: '/data/ds1/manifest.json',
106
+ summary: {
107
+ title: 'Summary Title',
108
+ description: '',
109
+ conceptCount: 10,
110
+ languages: ['eng'],
111
+ owner: '',
112
+ tags: [],
113
+ },
114
+ },
115
+ ]);
116
+ }
117
+ if (url.endsWith('manifest.json')) {
118
+ return mockJsonResponse({
119
+ id: 'ds1',
120
+ title: 'Full Title',
121
+ description: 'Full description',
122
+ owner: 'Test',
123
+ baseUrl: '/data/ds1',
124
+ languages: ['eng', 'fra'],
125
+ conceptCount: 50,
126
+ chunkSize: 500,
127
+ datasetUri: 'urn:test:ds1',
128
+ });
129
+ }
130
+ if (url.endsWith('index.json')) {
131
+ return mockJsonResponse({
132
+ registerId: 'ds1',
133
+ schemaVersion: '1.0',
134
+ conceptCount: 50,
135
+ chunkSize: 500,
136
+ chunks: [],
137
+ concepts: [],
138
+ });
139
+ }
140
+ return Promise.resolve({ ok: false, status: 404 } as Response);
141
+ });
142
+
143
+ // Discover with summary — no manifest fetch
144
+ const adapters = await factory.discoverDatasets('/datasets.json');
145
+ expect(adapters[0].manifest!.title).toBe('Summary Title');
146
+ expect(mockFetch).toHaveBeenCalledTimes(1);
147
+
148
+ // Load full dataset — fetches full manifest + index
149
+ const loaded = await factory.loadDataset('ds1');
150
+ expect(loaded.manifest!.title).toBe('Full Title');
151
+ expect(loaded.manifest!.conceptCount).toBe(50);
152
+ expect(loaded.manifest!.languages).toEqual(['eng', 'fra']);
153
+ });
154
+
155
+ describe('loadCrossRefIndex', () => {
156
+ it('fetches and caches cross-ref-index', async () => {
157
+ const factory = getFactory();
158
+
159
+ mockFetch.mockImplementation((url: string) => {
160
+ if (url.endsWith('cross-ref-index.json')) {
161
+ return mockJsonResponse({ 'ds1': ['ds2', 'ds3'] });
162
+ }
163
+ return Promise.resolve({ ok: false, status: 404 } as Response);
164
+ });
165
+
166
+ const index = await factory.loadCrossRefIndex();
167
+ expect(index).toEqual({ 'ds1': ['ds2', 'ds3'] });
168
+
169
+ // Second call should use cache
170
+ const index2 = await factory.loadCrossRefIndex();
171
+ expect(index2).toEqual({ 'ds1': ['ds2', 'ds3'] });
172
+ expect(mockFetch).toHaveBeenCalledTimes(1);
173
+ });
174
+
175
+ it('returns empty object on fetch failure', async () => {
176
+ const factory = getFactory();
177
+ mockFetch.mockResolvedValue({ ok: false, status: 404 } as Response);
178
+
179
+ const index = await factory.loadCrossRefIndex();
180
+ expect(index).toEqual({});
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,104 @@
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', overrides?: Partial<GraphNode>): GraphNode {
6
+ return {
7
+ uri,
8
+ register,
9
+ conceptId,
10
+ designations: { eng: conceptId },
11
+ status: 'valid',
12
+ loaded: true,
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function makeEdge(source: string, target: string, type = 'references', register = 'test'): GraphEdge {
18
+ return { source, target, type, register };
19
+ }
20
+
21
+ describe('GraphEngine.clear()', () => {
22
+ it('resets an empty engine', () => {
23
+ const g = new GraphEngine();
24
+ g.clear();
25
+ expect(g.nodeCount).toBe(0);
26
+ expect(g.edgeCount).toBe(0);
27
+ });
28
+
29
+ it('removes all nodes and edges', () => {
30
+ const g = new GraphEngine();
31
+ g.addNode(makeNode('uri:a', 'a'));
32
+ g.addNode(makeNode('uri:b', 'b'));
33
+ g.addEdge(makeEdge('uri:a', 'uri:b'));
34
+
35
+ expect(g.nodeCount).toBe(2);
36
+ expect(g.edgeCount).toBe(1);
37
+
38
+ g.clear();
39
+
40
+ expect(g.nodeCount).toBe(0);
41
+ expect(g.edgeCount).toBe(0);
42
+ expect(g.getNode('uri:a')).toBeUndefined();
43
+ expect(g.getEdges()).toEqual([]);
44
+ });
45
+
46
+ it('allows reuse after clear', () => {
47
+ const g = new GraphEngine();
48
+ g.addNode(makeNode('uri:a', 'a'));
49
+ g.clear();
50
+
51
+ g.addNode(makeNode('uri:b', 'b'));
52
+ expect(g.nodeCount).toBe(1);
53
+ expect(g.getNode('uri:b')?.conceptId).toBe('b');
54
+ });
55
+
56
+ it('clears adjacency indexes', () => {
57
+ const g = new GraphEngine();
58
+ g.addNode(makeNode('uri:a', 'a'));
59
+ g.addNode(makeNode('uri:b', 'b'));
60
+ g.addEdge(makeEdge('uri:a', 'uri:b'));
61
+
62
+ expect(g.getNeighbors('uri:a').outgoing).toEqual(['uri:b']);
63
+ g.clear();
64
+ expect(g.getNeighbors('uri:a').outgoing).toEqual([]);
65
+ });
66
+
67
+ it('allows re-adding previously deduplicated edges', () => {
68
+ const g = new GraphEngine();
69
+ g.addEdge(makeEdge('uri:a', 'uri:b', 'references'));
70
+ g.addEdge(makeEdge('uri:a', 'uri:b', 'references')); // deduped
71
+ expect(g.edgeCount).toBe(1);
72
+
73
+ g.clear();
74
+
75
+ g.addEdge(makeEdge('uri:a', 'uri:b', 'references'));
76
+ g.addEdge(makeEdge('uri:a', 'uri:b', 'references')); // deduped again
77
+ expect(g.edgeCount).toBe(1);
78
+ });
79
+ });
80
+
81
+ describe('GraphEngine.getSubgraph BFS performance', () => {
82
+ it('traverses linear chain correctly', () => {
83
+ const g = new GraphEngine();
84
+ for (let i = 0; i < 5; i++) {
85
+ g.addNode(makeNode(`uri:${i}`, `${i}`));
86
+ if (i > 0) g.addEdge(makeEdge(`uri:${i - 1}`, `uri:${i}`));
87
+ }
88
+
89
+ const sub = g.getSubgraph('uri:0', 2);
90
+ expect(sub.nodes.length).toBe(3); // 0, 1, 2
91
+ });
92
+
93
+ it('traverses fan-out graph correctly', () => {
94
+ const g = new GraphEngine();
95
+ g.addNode(makeNode('uri:root', 'root'));
96
+ for (let i = 1; i <= 10; i++) {
97
+ g.addNode(makeNode(`uri:${i}`, `${i}`));
98
+ g.addEdge(makeEdge('uri:root', `uri:${i}`));
99
+ }
100
+
101
+ const sub = g.getSubgraph('uri:root', 1);
102
+ expect(sub.nodes.length).toBe(11); // root + 10 children
103
+ });
104
+ });