@glossarist/concept-browser 0.1.0
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/README.md +319 -0
- package/cli/index.mjs +119 -0
- package/env.d.ts +7 -0
- package/index.html +16 -0
- package/package.json +78 -0
- package/postcss.config.js +6 -0
- package/scripts/build-edges.js +112 -0
- package/scripts/fetch-datasets.mjs +195 -0
- package/scripts/generate-404.js +15 -0
- package/scripts/generate-data.mjs +606 -0
- package/scripts/load-site-config.mjs +56 -0
- package/src/App.vue +98 -0
- package/src/__tests__/data-integration.test.ts +135 -0
- package/src/__tests__/data-integrity.test.ts +101 -0
- package/src/__tests__/dataset-adapter.test.ts +336 -0
- package/src/__tests__/dataset-style.test.ts +37 -0
- package/src/__tests__/graph.test.ts +187 -0
- package/src/__tests__/lang.test.ts +29 -0
- package/src/__tests__/math.test.ts +113 -0
- package/src/__tests__/reference-resolver.test.ts +122 -0
- package/src/__tests__/site-config.test.ts +52 -0
- package/src/__tests__/uri-router.test.ts +76 -0
- package/src/adapters/DatasetAdapter.ts +270 -0
- package/src/adapters/ReferenceResolver.ts +95 -0
- package/src/adapters/UriRouter.ts +41 -0
- package/src/adapters/factory.ts +78 -0
- package/src/adapters/types.ts +162 -0
- package/src/components/AppHeader.vue +99 -0
- package/src/components/AppSidebar.vue +133 -0
- package/src/components/ConceptCard.vue +65 -0
- package/src/components/ConceptDetail.vue +540 -0
- package/src/components/ConceptTimeline.vue +410 -0
- package/src/components/FormatDownloads.vue +46 -0
- package/src/components/GraphPanel.vue +499 -0
- package/src/components/LanguageDetail.vue +211 -0
- package/src/components/NavIcon.vue +20 -0
- package/src/components/SearchBar.vue +241 -0
- package/src/composables/use-dataset-loader.ts +27 -0
- package/src/config/types.ts +130 -0
- package/src/config/use-site-config.ts +144 -0
- package/src/graph/GraphEngine.ts +137 -0
- package/src/graph/index.ts +1 -0
- package/src/main.ts +11 -0
- package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/router/index.ts +43 -0
- package/src/router/page-routes.ts +35 -0
- package/src/stores/ui.ts +59 -0
- package/src/stores/vocabulary.ts +309 -0
- package/src/style.css +314 -0
- package/src/utils/asciidoc-lite.ts +123 -0
- package/src/utils/concept-formats.ts +157 -0
- package/src/utils/dataset-style.ts +54 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/lang.ts +32 -0
- package/src/utils/math.ts +100 -0
- package/src/views/AboutView.vue +122 -0
- package/src/views/ConceptView.vue +119 -0
- package/src/views/ContributorsView.vue +110 -0
- package/src/views/DatasetView.vue +249 -0
- package/src/views/GraphView.vue +65 -0
- package/src/views/HomeView.vue +168 -0
- package/src/views/NewsView.vue +146 -0
- package/src/views/ResolveView.vue +63 -0
- package/src/views/SearchView.vue +33 -0
- package/src/views/StatsView.vue +121 -0
- package/tailwind.config.js +43 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +27 -0
package/src/App.vue
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, onUnmounted, ref } from 'vue';
|
|
3
|
+
import { useVocabularyStore } from './stores/vocabulary';
|
|
4
|
+
import { useSiteConfig } from './config/use-site-config';
|
|
5
|
+
import { buildPageRoutes } from './router/page-routes';
|
|
6
|
+
import router from './router';
|
|
7
|
+
import AppHeader from './components/AppHeader.vue';
|
|
8
|
+
import AppSidebar from './components/AppSidebar.vue';
|
|
9
|
+
|
|
10
|
+
const store = useVocabularyStore();
|
|
11
|
+
const { loadConfig, config } = useSiteConfig();
|
|
12
|
+
const appReady = ref(false);
|
|
13
|
+
const showScrollTop = ref(false);
|
|
14
|
+
let mainEl: HTMLElement | null = null;
|
|
15
|
+
|
|
16
|
+
function onMainScroll() {
|
|
17
|
+
showScrollTop.value = (mainEl?.scrollTop ?? 0) > 400;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scrollToTop() {
|
|
21
|
+
mainEl?.scrollTo({ top: 0, behavior: 'smooth' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onMounted(async () => {
|
|
25
|
+
const [, cfg] = await Promise.all([store.discoverDatasets(), loadConfig()]);
|
|
26
|
+
if (cfg?.pages) {
|
|
27
|
+
for (const route of buildPageRoutes(cfg.pages)) {
|
|
28
|
+
router.addRoute(route);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
appReady.value = true;
|
|
32
|
+
// Watch scroll on main content area
|
|
33
|
+
mainEl = document.querySelector('main');
|
|
34
|
+
mainEl?.addEventListener('scroll', onMainScroll, { passive: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
onUnmounted(() => {
|
|
38
|
+
mainEl?.removeEventListener('scroll', onMainScroll);
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div class="min-h-screen bg-surface flex flex-col">
|
|
44
|
+
<AppHeader />
|
|
45
|
+
<div class="flex flex-1 overflow-hidden">
|
|
46
|
+
<AppSidebar />
|
|
47
|
+
<main class="flex-1 overflow-y-auto bg-surface">
|
|
48
|
+
<div v-if="!appReady" class="flex items-center justify-center h-[70vh]">
|
|
49
|
+
<div class="w-full max-w-md px-6 space-y-6">
|
|
50
|
+
<!-- Title skeleton -->
|
|
51
|
+
<div class="space-y-3">
|
|
52
|
+
<div class="skeleton h-3 w-24"></div>
|
|
53
|
+
<div class="skeleton h-10 w-64"></div>
|
|
54
|
+
<div class="skeleton h-4 w-80"></div>
|
|
55
|
+
<div class="skeleton h-4 w-56"></div>
|
|
56
|
+
</div>
|
|
57
|
+
<!-- Stats skeleton -->
|
|
58
|
+
<div class="flex gap-4">
|
|
59
|
+
<div class="skeleton h-16 flex-1"></div>
|
|
60
|
+
<div class="skeleton h-16 flex-1"></div>
|
|
61
|
+
<div class="skeleton h-16 flex-1"></div>
|
|
62
|
+
</div>
|
|
63
|
+
<!-- Card skeleton -->
|
|
64
|
+
<div class="flex gap-4">
|
|
65
|
+
<div class="skeleton h-40 flex-1"></div>
|
|
66
|
+
<div class="skeleton h-40 flex-1"></div>
|
|
67
|
+
<div class="skeleton h-40 flex-1"></div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<template v-else>
|
|
72
|
+
<div v-if="store.error && !store.currentConcept" class="max-w-xl mx-auto text-center py-24">
|
|
73
|
+
<div class="card p-8 border-red-200 bg-red-50/50">
|
|
74
|
+
<p class="text-red-700 font-medium mb-1">Error loading data</p>
|
|
75
|
+
<p class="text-sm text-red-600/80">{{ store.error }}</p>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<router-view v-slot="{ Component }">
|
|
79
|
+
<transition name="page" mode="out-in">
|
|
80
|
+
<component :is="Component" :key="$route.fullPath" />
|
|
81
|
+
</transition>
|
|
82
|
+
</router-view>
|
|
83
|
+
</template>
|
|
84
|
+
</main>
|
|
85
|
+
</div>
|
|
86
|
+
<!-- Scroll-to-top -->
|
|
87
|
+
<transition name="page">
|
|
88
|
+
<button
|
|
89
|
+
v-if="showScrollTop"
|
|
90
|
+
@click="scrollToTop"
|
|
91
|
+
aria-label="Scroll to top"
|
|
92
|
+
class="fixed bottom-6 right-6 z-40 w-10 h-10 bg-surface-raised border border-ink-100 rounded-full flex items-center justify-center shadow-md hover:shadow-lg hover:bg-ink-50 transition-all"
|
|
93
|
+
>
|
|
94
|
+
<svg class="w-4 h-4 text-ink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>
|
|
95
|
+
</button>
|
|
96
|
+
</transition>
|
|
97
|
+
</div>
|
|
98
|
+
</template>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { AdapterFactory } from '../adapters/factory';
|
|
3
|
+
|
|
4
|
+
const mockFetch = vi.fn();
|
|
5
|
+
global.fetch = mockFetch;
|
|
6
|
+
|
|
7
|
+
function mockJsonResponse(data: any) {
|
|
8
|
+
return Promise.resolve({
|
|
9
|
+
ok: true,
|
|
10
|
+
status: 200,
|
|
11
|
+
json: () => Promise.resolve(data),
|
|
12
|
+
} as Response);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('AdapterFactory', () => {
|
|
16
|
+
let factory: AdapterFactory;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
factory = new AdapterFactory();
|
|
20
|
+
mockFetch.mockReset();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('discoverDatasets', () => {
|
|
24
|
+
it('creates adapters from dataset registry', async () => {
|
|
25
|
+
mockFetch.mockReturnValue(mockJsonResponse([
|
|
26
|
+
{ id: 'iev', manifestUrl: '/data/iev/manifest.json' },
|
|
27
|
+
{ id: 'isotc211', manifestUrl: '/data/isotc211/manifest.json' },
|
|
28
|
+
{ id: 'isotc204', manifestUrl: '/data/isotc204/manifest.json' },
|
|
29
|
+
]));
|
|
30
|
+
|
|
31
|
+
const adapters = await factory.discoverDatasets('/datasets.json');
|
|
32
|
+
expect(adapters.length).toBe(3);
|
|
33
|
+
expect(adapters[0].registerId).toBe('iev');
|
|
34
|
+
expect(factory.getAdapters().length).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('throws on failed fetch', async () => {
|
|
38
|
+
mockFetch.mockReturnValue(Promise.resolve({
|
|
39
|
+
ok: false,
|
|
40
|
+
status: 500,
|
|
41
|
+
} as Response));
|
|
42
|
+
|
|
43
|
+
await expect(factory.discoverDatasets('/bad-url')).rejects.toThrow('Failed to load dataset registry');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('loadDataset', () => {
|
|
48
|
+
it('loads manifest, index, and registers with UriRouter', async () => {
|
|
49
|
+
// Setup: discover first
|
|
50
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse([
|
|
51
|
+
{ id: 'test', manifestUrl: '/data/test/manifest.json' },
|
|
52
|
+
]));
|
|
53
|
+
await factory.discoverDatasets('/datasets.json');
|
|
54
|
+
|
|
55
|
+
// Load manifest
|
|
56
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
57
|
+
id: 'test',
|
|
58
|
+
datasetUri: 'https://glossarist.org/test/*',
|
|
59
|
+
title: 'Test',
|
|
60
|
+
languages: ['eng'],
|
|
61
|
+
chunkSize: 500,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
// Load index
|
|
65
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
66
|
+
registerId: 'test',
|
|
67
|
+
conceptCount: 2,
|
|
68
|
+
chunkSize: 500,
|
|
69
|
+
chunks: [],
|
|
70
|
+
concepts: [
|
|
71
|
+
{ id: '102-01-01', eng: 'equality', status: 'Standard' },
|
|
72
|
+
{ id: '102-01-02', eng: 'value', status: 'Standard' },
|
|
73
|
+
],
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
const adapter = await factory.loadDataset('test');
|
|
77
|
+
expect(adapter.manifest?.title).toBe('Test');
|
|
78
|
+
expect(adapter.getConcepts().length).toBe(2);
|
|
79
|
+
|
|
80
|
+
// Resolver should now resolve internal URIs
|
|
81
|
+
const resolved = factory.resolve('https://glossarist.org/test/concept/102-01-01');
|
|
82
|
+
expect(resolved.type).toBe('internal');
|
|
83
|
+
if (resolved.type === 'internal') {
|
|
84
|
+
expect(resolved.registerId).toBe('test');
|
|
85
|
+
expect(resolved.conceptId).toBe('102-01-01');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns undefined for unknown dataset', () => {
|
|
90
|
+
expect(factory.getAdapter('nonexistent')).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('resolve returns unresolved when dataset not loaded', () => {
|
|
94
|
+
const resolved = factory.resolve('https://glossarist.org/unknown/concept/123');
|
|
95
|
+
expect(resolved.type).toBe('unresolved');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('cross-register resolution', () => {
|
|
100
|
+
it('resolves URIs across multiple loaded datasets', async () => {
|
|
101
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse([
|
|
102
|
+
{ id: 'iev', manifestUrl: '/data/iev/manifest.json' },
|
|
103
|
+
{ id: 'isotc204', manifestUrl: '/data/isotc204/manifest.json' },
|
|
104
|
+
]));
|
|
105
|
+
await factory.discoverDatasets('/datasets.json');
|
|
106
|
+
|
|
107
|
+
// Load IEV
|
|
108
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
109
|
+
id: 'iev', datasetUri: 'urn:iec:std:iec:60050:*', title: 'IEV', languages: ['eng'], chunkSize: 500,
|
|
110
|
+
}));
|
|
111
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
112
|
+
registerId: 'iev', conceptCount: 0, chunkSize: 500, chunks: [], concepts: [],
|
|
113
|
+
}));
|
|
114
|
+
await factory.loadDataset('iev');
|
|
115
|
+
|
|
116
|
+
// Load TC 204
|
|
117
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
118
|
+
id: 'isotc204', datasetUri: 'urn:iso:std:iso:14812:*', title: 'TC 204', languages: ['eng'], chunkSize: 500,
|
|
119
|
+
}));
|
|
120
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({
|
|
121
|
+
registerId: 'isotc204', conceptCount: 0, chunkSize: 500, chunks: [], concepts: [],
|
|
122
|
+
}));
|
|
123
|
+
await factory.loadDataset('isotc204');
|
|
124
|
+
|
|
125
|
+
// Cross-register resolution
|
|
126
|
+
const ievRes = factory.resolve('https://glossarist.org/iev/concept/103-01-02');
|
|
127
|
+
expect(ievRes.type).toBe('internal');
|
|
128
|
+
if (ievRes.type === 'internal') expect(ievRes.registerId).toBe('iev');
|
|
129
|
+
|
|
130
|
+
const tcRes = factory.resolve('https://glossarist.org/isotc204/concept/3.1.1.1');
|
|
131
|
+
expect(tcRes.type).toBe('internal');
|
|
132
|
+
if (tcRes.type === 'internal') expect(tcRes.registerId).toBe('isotc204');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
const PUBLIC_DATA = path.resolve(__dirname, '../../public/data');
|
|
6
|
+
const DATASETS_JSON = path.resolve(PUBLIC_DATA, '..', 'datasets.json');
|
|
7
|
+
|
|
8
|
+
const hasData = fs.existsSync(DATASETS_JSON);
|
|
9
|
+
const datasets: string[] = hasData
|
|
10
|
+
? JSON.parse(fs.readFileSync(DATASETS_JSON, 'utf8')).map((d: any) => d.id)
|
|
11
|
+
: [];
|
|
12
|
+
|
|
13
|
+
describe.skipIf(!hasData || datasets.length === 0)('Data integrity', () => {
|
|
14
|
+
it('has valid datasets.json', () => {
|
|
15
|
+
const data = JSON.parse(fs.readFileSync(DATASETS_JSON, 'utf8'));
|
|
16
|
+
expect(data.length).toBeGreaterThan(0);
|
|
17
|
+
for (const d of data) {
|
|
18
|
+
expect(d.id).toBeTruthy();
|
|
19
|
+
expect(d.manifestUrl).toBeTruthy();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
for (const ds of datasets) {
|
|
24
|
+
describe(`${ds}`, () => {
|
|
25
|
+
const dsDir = path.join(PUBLIC_DATA, ds);
|
|
26
|
+
|
|
27
|
+
it('has manifest.json with required fields', () => {
|
|
28
|
+
const file = path.join(dsDir, 'manifest.json');
|
|
29
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
30
|
+
const m = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
31
|
+
expect(m.id).toBe(ds);
|
|
32
|
+
expect(m.title).toBeTruthy();
|
|
33
|
+
expect(m.conceptCount).toBeGreaterThan(0);
|
|
34
|
+
expect(m.languages.length).toBeGreaterThan(0);
|
|
35
|
+
expect(m.chunkSize).toBe(500);
|
|
36
|
+
expect(m.baseUrl).toBe(`/data/${ds}`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('has index.json with valid summary entries', () => {
|
|
40
|
+
const file = path.join(dsDir, 'index.json');
|
|
41
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
42
|
+
const idx = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
expect(idx.registerId).toBe(ds);
|
|
44
|
+
expect(idx.conceptCount).toBe(idx.concepts.length);
|
|
45
|
+
expect(idx.chunkSize).toBe(500);
|
|
46
|
+
expect(idx.chunks.length).toBeGreaterThan(0);
|
|
47
|
+
|
|
48
|
+
for (const c of idx.concepts) {
|
|
49
|
+
expect(c.id).toBeTruthy();
|
|
50
|
+
expect(typeof c.eng).toBe('string');
|
|
51
|
+
expect(c.status).toBeTruthy();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('concept count matches files on disk', () => {
|
|
56
|
+
const conceptsDir = path.join(dsDir, 'concepts');
|
|
57
|
+
expect(fs.existsSync(conceptsDir)).toBe(true);
|
|
58
|
+
const files = fs.readdirSync(conceptsDir).filter(f => f.endsWith('.json'));
|
|
59
|
+
const idx = JSON.parse(fs.readFileSync(path.join(dsDir, 'index.json'), 'utf8'));
|
|
60
|
+
expect(files.length).toBe(idx.conceptCount);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('sample concepts are valid JSON-LD', () => {
|
|
64
|
+
const conceptsDir = path.join(dsDir, 'concepts');
|
|
65
|
+
const files = fs.readdirSync(conceptsDir).filter(f => f.endsWith('.json'));
|
|
66
|
+
|
|
67
|
+
const samples = [
|
|
68
|
+
files[0],
|
|
69
|
+
files[Math.floor(files.length / 2)],
|
|
70
|
+
files[files.length - 1],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const file of samples) {
|
|
74
|
+
const data = JSON.parse(fs.readFileSync(path.join(conceptsDir, file), 'utf8'));
|
|
75
|
+
expect(data['@context']).toBe('https://glossarist.org/ns/context.jsonld');
|
|
76
|
+
expect(data['@id']).toContain(`/concept/`);
|
|
77
|
+
expect(data['@type']).toBe('gl:Concept');
|
|
78
|
+
expect(data['gl:identifier']).toBeTruthy();
|
|
79
|
+
expect(data['gl:localizedConcept']).toBeTruthy();
|
|
80
|
+
expect(Object.keys(data['gl:localizedConcept']).length).toBeGreaterThan(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('index chunk files exist and have correct counts', () => {
|
|
85
|
+
const idx = JSON.parse(fs.readFileSync(path.join(dsDir, 'index.json'), 'utf8'));
|
|
86
|
+
const chunksDir = path.join(dsDir, 'chunks');
|
|
87
|
+
expect(fs.existsSync(chunksDir)).toBe(true);
|
|
88
|
+
|
|
89
|
+
let totalFromChunks = 0;
|
|
90
|
+
for (const chunk of idx.chunks) {
|
|
91
|
+
const chunkFile = path.join(chunksDir, chunk.file);
|
|
92
|
+
expect(fs.existsSync(chunkFile)).toBe(true);
|
|
93
|
+
const chunkData = JSON.parse(fs.readFileSync(chunkFile, 'utf8'));
|
|
94
|
+
expect(chunkData.concepts.length).toBe(chunk.count);
|
|
95
|
+
totalFromChunks += chunk.count;
|
|
96
|
+
}
|
|
97
|
+
expect(totalFromChunks).toBe(idx.conceptCount);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
3
|
+
|
|
4
|
+
// Mock fetch globally
|
|
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('DatasetAdapter', () => {
|
|
17
|
+
let adapter: DatasetAdapter;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
adapter = new DatasetAdapter('test', '/data/test');
|
|
21
|
+
mockFetch.mockReset();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('loadManifest', () => {
|
|
25
|
+
it('loads and stores the manifest', async () => {
|
|
26
|
+
const manifest = {
|
|
27
|
+
id: 'test',
|
|
28
|
+
title: 'Test Dataset',
|
|
29
|
+
description: 'A test',
|
|
30
|
+
owner: 'Test',
|
|
31
|
+
baseUrl: '/data/test',
|
|
32
|
+
languages: ['eng', 'fra'],
|
|
33
|
+
conceptCount: 100,
|
|
34
|
+
chunkSize: 500,
|
|
35
|
+
};
|
|
36
|
+
mockFetch.mockReturnValue(mockJsonResponse(manifest));
|
|
37
|
+
|
|
38
|
+
const result = await adapter.loadManifest();
|
|
39
|
+
expect(result.title).toBe('Test Dataset');
|
|
40
|
+
expect(adapter.manifest?.id).toBe('test');
|
|
41
|
+
expect(mockFetch).toHaveBeenCalledWith('/data/test/manifest.json');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws on fetch failure', async () => {
|
|
45
|
+
mockFetch.mockReturnValue(Promise.resolve({
|
|
46
|
+
ok: false,
|
|
47
|
+
status: 404,
|
|
48
|
+
} as Response));
|
|
49
|
+
|
|
50
|
+
await expect(adapter.loadManifest()).rejects.toThrow('Failed to load manifest');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('loadIndex', () => {
|
|
55
|
+
it('loads and indexes concept summaries', async () => {
|
|
56
|
+
const index = {
|
|
57
|
+
registerId: 'test',
|
|
58
|
+
schemaVersion: '1.0.0',
|
|
59
|
+
conceptCount: 3,
|
|
60
|
+
chunkSize: 500,
|
|
61
|
+
chunks: [],
|
|
62
|
+
concepts: [
|
|
63
|
+
{ id: '102-01-01', eng: 'equality', status: 'Standard' },
|
|
64
|
+
{ id: '102-01-02', eng: 'inequality', status: 'Standard' },
|
|
65
|
+
{ id: '103-01-02', eng: 'functional', status: 'Standard' },
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
mockFetch.mockReturnValue(mockJsonResponse(index));
|
|
69
|
+
|
|
70
|
+
await adapter.loadIndex();
|
|
71
|
+
expect(adapter.getConcepts().length).toBe(3);
|
|
72
|
+
expect(adapter.getIndexEntry('103-01-02')?.eng).toBe('functional');
|
|
73
|
+
expect(adapter.getConceptCount()).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('fetchConcept', () => {
|
|
78
|
+
it('fetches and caches concept documents', async () => {
|
|
79
|
+
const concept = {
|
|
80
|
+
'@context': 'https://glossarist.org/ns/context.jsonld',
|
|
81
|
+
'@id': 'https://glossarist.org/test/concept/103-01-02',
|
|
82
|
+
'@type': 'gl:Concept',
|
|
83
|
+
'gl:identifier': '103-01-02',
|
|
84
|
+
'gl:localizedConcept': {
|
|
85
|
+
eng: {
|
|
86
|
+
'@id': 'https://glossarist.org/test/concept/103-01-02/eng',
|
|
87
|
+
'@type': 'gl:LocalizedConcept',
|
|
88
|
+
'gl:languageCode': 'eng',
|
|
89
|
+
'gl:designation': [
|
|
90
|
+
{ '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'functional' },
|
|
91
|
+
],
|
|
92
|
+
'gl:definition': [
|
|
93
|
+
{ '@type': 'gl:DetailedDefinition', 'gl:content': 'A functional relationship...' },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
mockFetch.mockReturnValue(mockJsonResponse(concept));
|
|
99
|
+
|
|
100
|
+
const result = await adapter.fetchConcept('103-01-02');
|
|
101
|
+
expect(result['gl:identifier']).toBe('103-01-02');
|
|
102
|
+
expect(mockFetch).toHaveBeenCalledWith('/data/test/concepts/103-01-02.json');
|
|
103
|
+
|
|
104
|
+
// Second call should use cache
|
|
105
|
+
mockFetch.mockReset();
|
|
106
|
+
const cached = await adapter.fetchConcept('103-01-02');
|
|
107
|
+
expect(cached['gl:identifier']).toBe('103-01-02');
|
|
108
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws on missing concept', async () => {
|
|
112
|
+
mockFetch.mockReturnValue(Promise.resolve({
|
|
113
|
+
ok: false,
|
|
114
|
+
status: 404,
|
|
115
|
+
} as Response));
|
|
116
|
+
|
|
117
|
+
await expect(adapter.fetchConcept('nonexistent')).rejects.toThrow('not found');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('search', () => {
|
|
122
|
+
it('finds concepts by term substring', async () => {
|
|
123
|
+
const index = {
|
|
124
|
+
registerId: 'test',
|
|
125
|
+
schemaVersion: '1.0.0',
|
|
126
|
+
conceptCount: 3,
|
|
127
|
+
chunkSize: 500,
|
|
128
|
+
chunks: [],
|
|
129
|
+
concepts: [
|
|
130
|
+
{ id: '102-01-01', eng: 'equality', status: 'Standard' },
|
|
131
|
+
{ id: '102-01-02', eng: 'inequality', status: 'Standard' },
|
|
132
|
+
{ id: '103-01-02', eng: 'functional', status: 'Standard' },
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
mockFetch.mockReturnValue(mockJsonResponse(index));
|
|
136
|
+
await adapter.loadIndex();
|
|
137
|
+
|
|
138
|
+
const hits = adapter.search('func');
|
|
139
|
+
expect(hits.length).toBe(1);
|
|
140
|
+
expect(hits[0].conceptId).toBe('103-01-02');
|
|
141
|
+
expect(hits[0].designation).toBe('functional');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('finds concepts by ID', async () => {
|
|
145
|
+
const index = {
|
|
146
|
+
registerId: 'test',
|
|
147
|
+
schemaVersion: '1.0.0',
|
|
148
|
+
conceptCount: 1,
|
|
149
|
+
chunkSize: 500,
|
|
150
|
+
chunks: [],
|
|
151
|
+
concepts: [
|
|
152
|
+
{ id: '103-01-02', eng: 'functional', status: 'Standard' },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
mockFetch.mockReturnValue(mockJsonResponse(index));
|
|
156
|
+
await adapter.loadIndex();
|
|
157
|
+
|
|
158
|
+
const hits = adapter.search('103-01-02');
|
|
159
|
+
expect(hits.length).toBe(1);
|
|
160
|
+
expect(hits[0].conceptId).toBe('103-01-02');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('is case insensitive', async () => {
|
|
164
|
+
const index = {
|
|
165
|
+
registerId: 'test',
|
|
166
|
+
schemaVersion: '1.0.0',
|
|
167
|
+
conceptCount: 1,
|
|
168
|
+
chunkSize: 500,
|
|
169
|
+
chunks: [],
|
|
170
|
+
concepts: [
|
|
171
|
+
{ id: '102-01-01', eng: 'Equality', status: 'Standard' },
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
mockFetch.mockReturnValue(mockJsonResponse(index));
|
|
175
|
+
await adapter.loadIndex();
|
|
176
|
+
|
|
177
|
+
const hits = adapter.search('EQUALITY');
|
|
178
|
+
expect(hits.length).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('returns empty for no match', async () => {
|
|
182
|
+
const index = {
|
|
183
|
+
registerId: 'test',
|
|
184
|
+
schemaVersion: '1.0.0',
|
|
185
|
+
conceptCount: 1,
|
|
186
|
+
chunkSize: 500,
|
|
187
|
+
chunks: [],
|
|
188
|
+
concepts: [
|
|
189
|
+
{ id: '102-01-01', eng: 'equality', status: 'Standard' },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
mockFetch.mockReturnValue(mockJsonResponse(index));
|
|
193
|
+
await adapter.loadIndex();
|
|
194
|
+
|
|
195
|
+
const hits = adapter.search('xyznotfound');
|
|
196
|
+
expect(hits.length).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('extractEdges', () => {
|
|
201
|
+
it('extracts cross-reference edges from gl:references', () => {
|
|
202
|
+
const concept = {
|
|
203
|
+
'@id': 'https://glossarist.org/test/concept/102-01-01',
|
|
204
|
+
'gl:localizedConcept': {
|
|
205
|
+
eng: {
|
|
206
|
+
'gl:references': [
|
|
207
|
+
{ '@id': 'https://glossarist.org/iev/concept/103-01-02', 'gl:term': 'functional' },
|
|
208
|
+
{ '@id': 'https://glossarist.org/iev/concept/102-01-02', 'gl:term': 'inequality' },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const edges = adapter.extractEdges(concept as any);
|
|
215
|
+
expect(edges.length).toBe(2);
|
|
216
|
+
expect(edges[0].target).toBe('https://glossarist.org/iev/concept/103-01-02');
|
|
217
|
+
expect(edges[0].type).toBe('references');
|
|
218
|
+
expect(edges[0].label).toBe('functional');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('skips self-references', () => {
|
|
222
|
+
const concept = {
|
|
223
|
+
'@id': 'https://glossarist.org/test/concept/102-01-01',
|
|
224
|
+
'gl:localizedConcept': {
|
|
225
|
+
eng: {
|
|
226
|
+
'gl:references': [
|
|
227
|
+
{ '@id': 'https://glossarist.org/test/concept/102-01-01', 'gl:term': 'self' },
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const edges = adapter.extractEdges(concept as any);
|
|
234
|
+
expect(edges.length).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('handles concepts with no references', () => {
|
|
238
|
+
const concept = {
|
|
239
|
+
'@id': 'https://glossarist.org/test/concept/102-01-01',
|
|
240
|
+
'gl:localizedConcept': {
|
|
241
|
+
eng: {},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const edges = adapter.extractEdges(concept as any);
|
|
246
|
+
expect(edges.length).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('handles empty localizedConcept', () => {
|
|
250
|
+
const concept = {
|
|
251
|
+
'@id': 'https://glossarist.org/test/concept/102-01-01',
|
|
252
|
+
'gl:localizedConcept': {},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const edges = adapter.extractEdges(concept as any);
|
|
256
|
+
expect(edges.length).toBe(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('collects references from multiple languages without duplication', () => {
|
|
260
|
+
const concept = {
|
|
261
|
+
'@id': 'https://glossarist.org/test/concept/102-01-01',
|
|
262
|
+
'gl:localizedConcept': {
|
|
263
|
+
eng: {
|
|
264
|
+
'gl:references': [
|
|
265
|
+
{ '@id': 'https://glossarist.org/iev/concept/103-01-02', 'gl:term': 'functional' },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
fra: {
|
|
269
|
+
'gl:references': [
|
|
270
|
+
{ '@id': 'https://glossarist.org/iev/concept/103-01-02', 'gl:term': 'fonctionnel' },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Same target from two languages — both edges are kept (different labels)
|
|
277
|
+
const edges = adapter.extractEdges(concept as any);
|
|
278
|
+
expect(edges.length).toBe(2);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('extracts inline IEV cross-references from gl:references', () => {
|
|
282
|
+
const concept = {
|
|
283
|
+
'@id': 'https://glossarist.org/test/concept/112-01-01',
|
|
284
|
+
'gl:localizedConcept': {
|
|
285
|
+
eng: {
|
|
286
|
+
'gl:references': [
|
|
287
|
+
{ '@id': 'https://glossarist.org/iev/concept/102-02-18', 'gl:term': 'scalar' },
|
|
288
|
+
{ '@id': 'https://glossarist.org/iev/concept/112-01-14', 'gl:term': 'unit' },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const edges = adapter.extractEdges(concept as any);
|
|
295
|
+
expect(edges.length).toBe(2);
|
|
296
|
+
expect(edges[0].target).toBe('https://glossarist.org/iev/concept/102-02-18');
|
|
297
|
+
expect(edges[0].label).toBe('scalar');
|
|
298
|
+
expect(edges[1].target).toBe('https://glossarist.org/iev/concept/112-01-14');
|
|
299
|
+
expect(edges[1].label).toBe('unit');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('extracts inline URN cross-references from gl:references', () => {
|
|
303
|
+
const concept = {
|
|
304
|
+
'@id': 'https://glossarist.org/test/concept/3.1.1.1',
|
|
305
|
+
'gl:localizedConcept': {
|
|
306
|
+
eng: {
|
|
307
|
+
'gl:references': [
|
|
308
|
+
{ '@id': 'https://glossarist.org/isotc204/concept/3.1.1.6', 'gl:term': 'entity' },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const edges = adapter.extractEdges(concept as any);
|
|
315
|
+
expect(edges.length).toBe(1);
|
|
316
|
+
expect(edges[0].target).toBe('https://glossarist.org/isotc204/concept/3.1.1.6');
|
|
317
|
+
expect(edges[0].label).toBe('entity');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('getLanguages', () => {
|
|
322
|
+
it('returns languages from manifest', async () => {
|
|
323
|
+
const manifest = {
|
|
324
|
+
id: 'test', languages: ['eng', 'fra', 'deu'], chunkSize: 500,
|
|
325
|
+
};
|
|
326
|
+
mockFetch.mockReturnValue(mockJsonResponse(manifest));
|
|
327
|
+
await adapter.loadManifest();
|
|
328
|
+
|
|
329
|
+
expect(adapter.getLanguages()).toEqual(['eng', 'fra', 'deu']);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('returns empty array without manifest', () => {
|
|
333
|
+
expect(adapter.getLanguages()).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|