@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.
- package/index.html +2 -1
- package/package.json +2 -2
- 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 +1 -1
- package/src/__tests__/performance-v2.test.ts +77 -0
- package/src/__tests__/performance.test.ts +95 -0
- package/src/__tests__/relationship-categories.test.ts +3 -3
- 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 +124 -55
- 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 +246 -18
- 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 +15 -6
- 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
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=
|
|
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.
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"autoprefixer": "^10.4.21",
|
|
26
26
|
"d3": "^7.9.0",
|
|
27
27
|
"favicons": "^7.2.0",
|
|
28
|
-
"glossarist": "^0.3.
|
|
28
|
+
"glossarist": "^0.3.3",
|
|
29
29
|
"js-yaml": "^4.1.0",
|
|
30
30
|
"pinia": "^2.3.1",
|
|
31
31
|
"postcss": "^8.5.3",
|
package/scripts/build-edges.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
45
|
-
const lang = doc.data.language_code;
|
|
46
|
-
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
15
|
+
const mainRef = ref<HTMLElement | null>(null);
|
|
16
16
|
|
|
17
17
|
function onMainScroll() {
|
|
18
|
-
showScrollTop.value = (
|
|
18
|
+
showScrollTop.value = (mainRef.value?.scrollTop ?? 0) > 400;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function scrollToTop() {
|
|
22
|
-
|
|
22
|
+
mainRef.value?.scrollTo({ top: 0, behavior: 'smooth' });
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
onMounted(async () => {
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
document.title =
|
|
26
|
+
await Promise.all([store.discoverDatasets(), loadConfig()]);
|
|
27
|
+
if (config.value?.title) {
|
|
28
|
+
document.title = config.value.title;
|
|
29
29
|
}
|
|
30
|
-
initLocale(
|
|
30
|
+
initLocale(config.value?.defaults?.language);
|
|
31
31
|
appReady.value = true;
|
|
32
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
+
});
|