@glossarist/concept-browser 0.7.138 → 0.7.140
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/package.json +1 -1
- package/scripts/__tests__/standards-index.test.ts +121 -0
- package/scripts/build-edges.js +20 -3
- package/scripts/build-edges.ts +1 -6
- package/scripts/generate-data.js +51 -3
- package/scripts/generate-data.ts +37 -3
- package/src/__fixtures__/isotc211-domain-slugs.json +201 -0
- package/src/__tests__/app-footer.test.ts +36 -0
- package/src/__tests__/app-header.test.ts +32 -5
- package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
- package/src/__tests__/concept-view.test.ts +103 -0
- package/src/__tests__/dataset-adapter.test.ts +151 -1
- package/src/__tests__/dataset-view.test.ts +41 -1
- package/src/__tests__/model-bridge-citation.test.ts +20 -0
- package/src/__tests__/same-standard-pane.test.ts +174 -0
- package/src/__tests__/scripts/generate-data.test.ts +74 -0
- package/src/__tests__/search-across-datasets.test.ts +102 -0
- package/src/__tests__/search-bar.test.ts +304 -0
- package/src/__tests__/search-results.test.ts +54 -0
- package/src/__tests__/search-utils.test.ts +64 -1
- package/src/__tests__/search-view.test.ts +194 -2
- package/src/__tests__/slugify.test.ts +74 -1
- package/src/__tests__/source-standard.test.ts +70 -0
- package/src/__tests__/ui-store.test.ts +26 -0
- package/src/__tests__/vocabulary-layered.test.ts +7 -7
- package/src/adapters/DatasetAdapter.ts +16 -8
- package/src/adapters/jsonld-types.ts +1 -0
- package/src/adapters/model-bridge.ts +1 -0
- package/src/adapters/standards-index.ts +7 -0
- package/src/adapters/types.ts +1 -0
- package/src/components/AppFooter.vue +7 -0
- package/src/components/AppHeader.vue +6 -5
- package/src/components/SameStandardPane.vue +70 -0
- package/src/components/SearchBar.vue +73 -18
- package/src/components/SearchResults.vue +46 -5
- package/src/components/site/SiteFooter.astro +6 -0
- package/src/composables/use-same-standard-terms.ts +106 -0
- package/src/config/types.ts +11 -0
- package/src/config/use-site-config.ts +1 -0
- package/src/i18n/locales/eng.yml +10 -1
- package/src/i18n/locales/fra.yml +10 -1
- package/src/stores/ui.ts +16 -1
- package/src/stores/vocabulary.ts +27 -31
- package/src/utils/search.ts +14 -1
- package/src/utils/slugify.ts +25 -1
- package/src/utils/source-standard.ts +34 -0
- package/src/views/ConceptView.vue +43 -12
- package/src/views/DatasetView.vue +25 -5
- package/src/views/SearchView.vue +9 -12
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
3
|
+
import { ChunkLoadError } from '../errors';
|
|
3
4
|
import { conceptFromJson } from '../adapters/model-bridge';
|
|
4
5
|
|
|
5
6
|
// Mock fetch globally
|
|
@@ -132,6 +133,127 @@ describe('DatasetAdapter', () => {
|
|
|
132
133
|
});
|
|
133
134
|
});
|
|
134
135
|
|
|
136
|
+
describe('chunk URLs', () => {
|
|
137
|
+
it.each([undefined, 'abc123def456'])('uses metadata filenames and optional hash %s for summary chunks', async (hash) => {
|
|
138
|
+
adapter.manifest = { conceptCount: 1001 } as any;
|
|
139
|
+
const first = { id: 'first', designations: { eng: 'First' }, status: 'valid' };
|
|
140
|
+
const last = { id: 'last', designations: { eng: 'Last' }, status: 'valid' };
|
|
141
|
+
mockFetch
|
|
142
|
+
.mockReturnValueOnce(mockJsonResponse({
|
|
143
|
+
registerId: 'test', schemaVersion: '1.0.0', conceptCount: 1001, chunkSize: 1000,
|
|
144
|
+
chunks: [
|
|
145
|
+
{ file: 'first.json', count: 1000, hash },
|
|
146
|
+
{ file: 'last.json', count: 1, hash },
|
|
147
|
+
],
|
|
148
|
+
}))
|
|
149
|
+
.mockReturnValueOnce(mockJsonResponse({ concepts: [first] }))
|
|
150
|
+
.mockReturnValueOnce(mockJsonResponse({ concepts: [last] }));
|
|
151
|
+
|
|
152
|
+
await adapter.loadIndex();
|
|
153
|
+
await adapter.ensureAllChunksLoaded();
|
|
154
|
+
|
|
155
|
+
const query = hash ? `?v=${hash}` : '';
|
|
156
|
+
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
|
|
157
|
+
'/data/test/index-meta.json',
|
|
158
|
+
`/data/test/chunks/first.json${query}`,
|
|
159
|
+
`/data/test/chunks/last.json${query}`,
|
|
160
|
+
]);
|
|
161
|
+
expect(adapter.getIndexEntry('first')).toMatchObject(first);
|
|
162
|
+
expect(adapter.getIndexEntry('last')).toMatchObject(last);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it.each([undefined, 'abc123def456'])('uses index metadata and optional hash %s for full chunks', async (hash) => {
|
|
166
|
+
const entry = { id: 'first', designations: { eng: 'First' }, status: 'valid' };
|
|
167
|
+
mockFetch
|
|
168
|
+
.mockReturnValueOnce(mockJsonResponse({
|
|
169
|
+
registerId: 'test', schemaVersion: '1.0.0', conceptCount: 1, chunkSize: 1000,
|
|
170
|
+
chunks: [{ file: 'custom.json', count: 1, hash }], concepts: [entry],
|
|
171
|
+
}))
|
|
172
|
+
.mockReturnValueOnce(mockJsonResponse({ concepts: [entry] }));
|
|
173
|
+
|
|
174
|
+
await adapter.loadIndex();
|
|
175
|
+
expect(await adapter.loadChunk(0)).toEqual([entry]);
|
|
176
|
+
expect(mockFetch).toHaveBeenLastCalledWith(`/data/test/chunks/custom.json${hash ? `?v=${hash}` : ''}`);
|
|
177
|
+
expect(await adapter.loadChunk(0)).toEqual([]);
|
|
178
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('keeps the legacy filename when no index metadata is available', async () => {
|
|
182
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({ concepts: [] }));
|
|
183
|
+
await adapter.loadChunk(2);
|
|
184
|
+
expect(mockFetch).toHaveBeenCalledWith('/data/test/chunks/index-0002.json');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('chunked index failures', () => {
|
|
189
|
+
it('rejects loadIndex when its first chunk fails and allows a retry', async () => {
|
|
190
|
+
adapter.manifest = { conceptCount: 1001 } as any;
|
|
191
|
+
const meta = {
|
|
192
|
+
registerId: 'test', schemaVersion: '1.0.0', conceptCount: 1001, chunkSize: 1000,
|
|
193
|
+
chunks: [{ file: 'index-0000.json', count: 1000 }, { file: 'index-0001.json', count: 1 }],
|
|
194
|
+
};
|
|
195
|
+
mockFetch
|
|
196
|
+
.mockReturnValueOnce(mockJsonResponse(meta))
|
|
197
|
+
.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
|
198
|
+
|
|
199
|
+
await expect(adapter.loadIndex()).rejects.toBeInstanceOf(ChunkLoadError);
|
|
200
|
+
expect(adapter.isRangeLoaded(0, 1)).toBe(false);
|
|
201
|
+
|
|
202
|
+
mockFetch
|
|
203
|
+
.mockReturnValueOnce(mockJsonResponse(meta))
|
|
204
|
+
.mockReturnValueOnce(mockJsonResponse({ concepts: [
|
|
205
|
+
{ id: 'first', designations: { eng: 'First' }, status: 'valid' },
|
|
206
|
+
] }));
|
|
207
|
+
await adapter.loadIndex();
|
|
208
|
+
|
|
209
|
+
expect(adapter.isRangeLoaded(0, 1)).toBe(true);
|
|
210
|
+
expect(adapter.getIndexEntry('first')?.eng).toBe('First');
|
|
211
|
+
expect(mockFetch).toHaveBeenNthCalledWith(4, '/data/test/chunks/index-0000.json');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it.each(['all', 'range'] as const)('rejects a missing chunk and retries it when loading %s chunks', async (mode) => {
|
|
215
|
+
adapter.manifest = { conceptCount: 1002 } as any;
|
|
216
|
+
const first = { id: 'first', designations: { eng: 'First' }, status: 'valid' };
|
|
217
|
+
const missing = { id: 'missing', designations: { eng: 'Recovered' }, status: 'valid' };
|
|
218
|
+
mockFetch
|
|
219
|
+
.mockReturnValueOnce(mockJsonResponse({
|
|
220
|
+
registerId: 'test', schemaVersion: '1.0.0', conceptCount: 1002, chunkSize: 1000,
|
|
221
|
+
chunks: [{ file: 'index-0000.json', count: 1000 }, { file: 'index-0001.json', count: 2 }],
|
|
222
|
+
}))
|
|
223
|
+
.mockReturnValueOnce(mockJsonResponse({ concepts: [first] }))
|
|
224
|
+
.mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
|
225
|
+
await adapter.loadIndex();
|
|
226
|
+
const ensureLoaded = () => mode === 'all'
|
|
227
|
+
? adapter.ensureAllChunksLoaded()
|
|
228
|
+
: adapter.ensureChunksForRange(1000, 2);
|
|
229
|
+
|
|
230
|
+
const failedLoad = ensureLoaded();
|
|
231
|
+
await expect(failedLoad).rejects.toBeInstanceOf(ChunkLoadError);
|
|
232
|
+
await expect(failedLoad).rejects.toMatchObject({
|
|
233
|
+
context: { registerId: 'test', chunkIndex: 1, status: 404 },
|
|
234
|
+
});
|
|
235
|
+
expect(adapter.isRangeLoaded(1000, 2)).toBe(false);
|
|
236
|
+
expect(adapter.getIndexEntry('missing')).toBeUndefined();
|
|
237
|
+
expect(adapter.getIndexEntry('first')).toMatchObject(first);
|
|
238
|
+
|
|
239
|
+
mockFetch.mockReturnValueOnce(mockJsonResponse({ concepts: [
|
|
240
|
+
missing, { id: 'last', designations: { eng: 'Last' }, status: 'valid' },
|
|
241
|
+
] }));
|
|
242
|
+
await ensureLoaded();
|
|
243
|
+
|
|
244
|
+
expect(adapter.isRangeLoaded(1000, 2)).toBe(true);
|
|
245
|
+
expect(adapter.index?.concepts.slice(1000)).toMatchObject([missing, { id: 'last' }]);
|
|
246
|
+
expect(adapter.getIndexEntry('missing')).toMatchObject(missing);
|
|
247
|
+
expect(adapter.getIndexEntry('first')).toMatchObject(first);
|
|
248
|
+
expect(mockFetch.mock.calls.map(([url]) => url)).toEqual([
|
|
249
|
+
'/data/test/index-meta.json', '/data/test/chunks/index-0000.json',
|
|
250
|
+
'/data/test/chunks/index-0001.json', '/data/test/chunks/index-0001.json',
|
|
251
|
+
]);
|
|
252
|
+
await ensureLoaded();
|
|
253
|
+
expect(mockFetch).toHaveBeenCalledTimes(4);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
135
257
|
describe('fetchConcept', () => {
|
|
136
258
|
it('fetches and caches concept documents', async () => {
|
|
137
259
|
const concept = {
|
|
@@ -218,6 +340,34 @@ describe('DatasetAdapter', () => {
|
|
|
218
340
|
expect(hits[0].conceptId).toBe('103-01-02');
|
|
219
341
|
});
|
|
220
342
|
|
|
343
|
+
it('carries concept status on designation and ID search hits', async () => {
|
|
344
|
+
const index = {
|
|
345
|
+
registerId: 'test',
|
|
346
|
+
schemaVersion: '1.0.0',
|
|
347
|
+
conceptCount: 2,
|
|
348
|
+
chunkSize: 500,
|
|
349
|
+
chunks: [],
|
|
350
|
+
concepts: [
|
|
351
|
+
{ id: '102-01-01', designations: { eng: 'shared term' }, eng: 'shared term', status: 'valid' },
|
|
352
|
+
{ id: '102-01-02', designations: { eng: 'shared term' }, eng: 'shared term', status: 'superseded' },
|
|
353
|
+
],
|
|
354
|
+
};
|
|
355
|
+
mockFetch.mockReturnValue(mockJsonResponse(index));
|
|
356
|
+
await adapter.loadIndex();
|
|
357
|
+
|
|
358
|
+
const designationHits = adapter.search('shared term');
|
|
359
|
+
expect(Object.fromEntries(designationHits.map(({ conceptId, status }) => [conceptId, status]))).toEqual({
|
|
360
|
+
'102-01-01': 'valid',
|
|
361
|
+
'102-01-02': 'superseded',
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const idHits = ['102-01-01', '102-01-02'].flatMap((id) => adapter.search(id));
|
|
365
|
+
expect(Object.fromEntries(idHits.map(({ conceptId, status }) => [conceptId, status]))).toEqual({
|
|
366
|
+
'102-01-01': 'valid',
|
|
367
|
+
'102-01-02': 'superseded',
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
221
371
|
it('is case insensitive', async () => {
|
|
222
372
|
const index = {
|
|
223
373
|
registerId: 'test',
|
|
@@ -548,7 +698,7 @@ describe('DatasetAdapter', () => {
|
|
|
548
698
|
expect(edges.length).toBe(2);
|
|
549
699
|
expect(edges.every(e => e.type === 'domain')).toBe(true);
|
|
550
700
|
expect(edges.find(e => e.lang === 'eng')?.target).toContain('/domain/geometry');
|
|
551
|
-
expect(edges.find(e => e.lang === 'fra')?.target).toContain('/domain/
|
|
701
|
+
expect(edges.find(e => e.lang === 'fra')?.target).toContain('/domain/géométrie');
|
|
552
702
|
expect(edges.find(e => e.lang === 'eng')?.label).toBe('geometry');
|
|
553
703
|
expect(edges.find(e => e.lang === 'fra')?.label).toBe('géométrie');
|
|
554
704
|
});
|
|
@@ -1,10 +1,11 @@
|
|
|
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 { createPinia, setActivePinia } from 'pinia';
|
|
4
4
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
5
|
import DatasetView from '../views/DatasetView.vue';
|
|
6
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
7
|
import type { Manifest, ConceptSummary, SectionNode } from '../adapters/types';
|
|
8
|
+
import { ChunkLoadError } from '../errors';
|
|
8
9
|
|
|
9
10
|
function makeManifest(overrides: Partial<Manifest> = {}): Manifest {
|
|
10
11
|
return {
|
|
@@ -137,6 +138,45 @@ describe('DatasetView', () => {
|
|
|
137
138
|
expect(wrapper.text()).toContain('Next');
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
it.each(['page', 'filter'] as const)('shows a chunk failure instead of remaining loading after a %s change', async (action) => {
|
|
142
|
+
const store = useVocabularyStore();
|
|
143
|
+
const adapter = makeAdapter(makeConcepts(120));
|
|
144
|
+
const error = ChunkLoadError.make('test', 1, 503);
|
|
145
|
+
let failing = true;
|
|
146
|
+
adapter.isRangeLoaded = (start: number) => start === 0;
|
|
147
|
+
adapter.ensureChunksForRange = vi.fn(async () => { if (failing) throw error; });
|
|
148
|
+
adapter.ensureAllChunksLoaded = vi.fn(async () => { if (failing) throw error; });
|
|
149
|
+
store.manifests.set('test', makeManifest());
|
|
150
|
+
store.datasets.set('test', adapter);
|
|
151
|
+
const wrapper = mount(DatasetView, {
|
|
152
|
+
global: { plugins: [pinia, router] },
|
|
153
|
+
props: { registerId: 'test' },
|
|
154
|
+
});
|
|
155
|
+
await flushPromises();
|
|
156
|
+
expect(wrapper.text()).toContain('term 1');
|
|
157
|
+
|
|
158
|
+
if (action === 'page') {
|
|
159
|
+
await wrapper.findAll('button').find(b => b.text().includes('Next'))!.trigger('click');
|
|
160
|
+
} else {
|
|
161
|
+
await wrapper.find('input[aria-label="Filter concepts"]').setValue('term');
|
|
162
|
+
}
|
|
163
|
+
await flushPromises();
|
|
164
|
+
|
|
165
|
+
expect(wrapper.text()).toContain(error.message);
|
|
166
|
+
expect(wrapper.findAll('button').some(b => b.text() === 'Retry')).toBe(true);
|
|
167
|
+
expect(wrapper.find('.skeleton').exists()).toBe(false);
|
|
168
|
+
expect((wrapper.vm as unknown as { chunkLoading: boolean }).chunkLoading).toBe(false);
|
|
169
|
+
|
|
170
|
+
failing = false;
|
|
171
|
+
await wrapper.findAll('button').find(b => b.text() === 'Retry')!.trigger('click');
|
|
172
|
+
await flushPromises();
|
|
173
|
+
expect(wrapper.text()).not.toContain(error.message);
|
|
174
|
+
expect(wrapper.text()).toContain(action === 'page' ? 'term 51' : 'term 1');
|
|
175
|
+
expect(wrapper.find('.skeleton').exists()).toBe(false);
|
|
176
|
+
expect(action === 'page' ? adapter.ensureChunksForRange : adapter.ensureAllChunksLoaded).toHaveBeenCalledTimes(2);
|
|
177
|
+
wrapper.unmount();
|
|
178
|
+
});
|
|
179
|
+
|
|
140
180
|
it('shows correct page count in pagination', async () => {
|
|
141
181
|
const wrapper = mountDataset(makeConcepts(120));
|
|
142
182
|
await flushPromises();
|
|
@@ -2,6 +2,26 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { conceptFromJson, getRelatedSourceId, getRelatedCitation } from '../adapters/model-bridge';
|
|
3
3
|
|
|
4
4
|
describe('model-bridge — source id mapping', () => {
|
|
5
|
+
it('preserves concept-level sources alongside localized sources', () => {
|
|
6
|
+
const source = (id: string, type = 'authoritative') => ({
|
|
7
|
+
'gl:sourceType': type,
|
|
8
|
+
'gl:origin': { 'gl:ref': { 'gl:source': 'ISO', 'gl:id': id } },
|
|
9
|
+
});
|
|
10
|
+
const concept = conceptFromJson({
|
|
11
|
+
'@type': 'gl:Concept',
|
|
12
|
+
'gl:identifier': '1',
|
|
13
|
+
'gl:source': [source('19165-2:2020'), source('704:2022', 'lineage')],
|
|
14
|
+
'gl:localizedConcept': { eng: { 'gl:source': [source('1087:2019')] } },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(concept.sources.map(s => [s.type, s.origin?.ref?.source, s.origin?.ref?.id])).toEqual([
|
|
18
|
+
['authoritative', 'ISO', '19165-2:2020'],
|
|
19
|
+
['lineage', 'ISO', '704:2022'],
|
|
20
|
+
]);
|
|
21
|
+
expect(concept.localization('eng')!.sources.map(s => s.origin?.ref?.id)).toEqual(['1087:2019']);
|
|
22
|
+
expect(conceptFromJson({ '@type': 'gl:Concept', 'gl:identifier': '2' }).sources).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
5
25
|
it('maps gl:id from JSON-LD ConceptSource', () => {
|
|
6
26
|
const doc = {
|
|
7
27
|
'@type': 'gl:Concept',
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
6
|
+
import { collectStandardKeys } from '../utils/source-standard';
|
|
7
|
+
import { useSameStandardTerms } from '../composables/use-same-standard-terms';
|
|
8
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { flushPromises, mount } from '@vue/test-utils';
|
|
11
|
+
import { RouterLink } from 'vue-router';
|
|
12
|
+
import SameStandardPane from '../components/SameStandardPane.vue';
|
|
13
|
+
import type { SameStandardTerms } from '../composables/use-same-standard-terms';
|
|
14
|
+
import { createTestRouter, setupPinia } from './test-helpers';
|
|
15
|
+
|
|
16
|
+
const groups: SameStandardTerms[] = [
|
|
17
|
+
{
|
|
18
|
+
key: 'iso-123', label: 'ISO 123:2024', terms: [
|
|
19
|
+
{ conceptId: 'current', designation: 'Current term' },
|
|
20
|
+
{ conceptId: 'other', designation: 'Related term' },
|
|
21
|
+
{ conceptId: 'third', designation: 'Third term' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
{ key: 'iec-456', label: 'IEC 456:2020', terms: [{ conceptId: 'fourth', designation: 'Fourth term' }] },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('SameStandardPane', () => {
|
|
28
|
+
let pinia: ReturnType<typeof setupPinia>;
|
|
29
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
localStorage.clear();
|
|
33
|
+
pinia = setupPinia();
|
|
34
|
+
router = await createTestRouter('dataset');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => vi.restoreAllMocks());
|
|
38
|
+
|
|
39
|
+
function mountPane(paneGroups = groups) {
|
|
40
|
+
return mount(SameStandardPane, {
|
|
41
|
+
props: { groups: paneGroups, registerId: 'test-register', conceptId: 'current' },
|
|
42
|
+
global: { plugins: [pinia, router] },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('renders peers from generated concept-level sources in a 1200-concept register', async () => {
|
|
47
|
+
const root = mkdtempSync(join(tmpdir(), 'concept-source-pane-'));
|
|
48
|
+
let wrapper: ReturnType<typeof mount> | undefined;
|
|
49
|
+
try {
|
|
50
|
+
const conceptsDir = join(root, 'source', 'concepts');
|
|
51
|
+
const configPath = join(root, 'site-config.yml');
|
|
52
|
+
mkdirSync(conceptsDir, { recursive: true });
|
|
53
|
+
writeFileSync(configPath, JSON.stringify({
|
|
54
|
+
id: 'fixture', title: 'Fixture', uri_base: 'https://example.test',
|
|
55
|
+
datasets: [{ id: 'fixture', local_path: 'source', languages: ['eng'] }],
|
|
56
|
+
}));
|
|
57
|
+
for (let id = 1; id <= 1200; id++) {
|
|
58
|
+
writeFileSync(join(conceptsDir, `${id}.yaml`), JSON.stringify({
|
|
59
|
+
termid: String(id),
|
|
60
|
+
_sources: [1, 600, 1100].includes(id)
|
|
61
|
+
? [{ type: 'authoritative', origin: { ref: { source: 'ISO', id: '19165-2:2020' } } }]
|
|
62
|
+
: [],
|
|
63
|
+
eng: { terms: [{ designation: `Fixture term ${id}` }] },
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
execFileSync(process.execPath, [
|
|
67
|
+
'--import', resolve('node_modules/tsx/dist/loader.mjs'), resolve('scripts/generate-data.ts'),
|
|
68
|
+
], { cwd: root, env: { ...process.env, SITE_CONFIG: configPath }, stdio: 'pipe', timeout: 30_000 });
|
|
69
|
+
const readJson = (url: string) => JSON.parse(readFileSync(join(root, 'public', url), 'utf8'));
|
|
70
|
+
expect(readJson('/data/fixture/standards.json')).toEqual({
|
|
71
|
+
'iso 19165-2:2020': { label: 'ISO 19165-2:2020', conceptIds: ['1', '600', '1100'] },
|
|
72
|
+
});
|
|
73
|
+
vi.stubGlobal('fetch', vi.fn(async (url: string) => ({ ok: true, json: async () => readJson(url) })));
|
|
74
|
+
const adapter = new DatasetAdapter('fixture', '/data/fixture');
|
|
75
|
+
await adapter.loadIndex();
|
|
76
|
+
const concept = await adapter.fetchConcept('1');
|
|
77
|
+
expect(concept.sources).toHaveLength(1);
|
|
78
|
+
expect(concept.localization('eng')!.sources).toEqual([]);
|
|
79
|
+
expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020']);
|
|
80
|
+
const store = useVocabularyStore();
|
|
81
|
+
store.datasets.set('fixture', adapter);
|
|
82
|
+
store.currentRegisterId = 'fixture';
|
|
83
|
+
store.currentConcept = concept;
|
|
84
|
+
wrapper = mount({
|
|
85
|
+
components: { SameStandardPane },
|
|
86
|
+
setup: () => ({ groups: useSameStandardTerms('/') }),
|
|
87
|
+
template: '<SameStandardPane :groups="groups" register-id="fixture" concept-id="1" />',
|
|
88
|
+
}, { global: { plugins: [pinia, router] } });
|
|
89
|
+
await vi.waitFor(() => expect(wrapper!.findAll('a').map(link => link.text())).toEqual([
|
|
90
|
+
'Fixture term 600', 'Fixture term 1100',
|
|
91
|
+
]));
|
|
92
|
+
expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual([
|
|
93
|
+
'/dataset/fixture/concept/600', '/dataset/fixture/concept/1100',
|
|
94
|
+
]);
|
|
95
|
+
expect(wrapper.get('h3').text()).toContain('ISO 19165-2:2020');
|
|
96
|
+
} finally {
|
|
97
|
+
wrapper?.unmount();
|
|
98
|
+
vi.unstubAllGlobals();
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}, 60_000);
|
|
102
|
+
|
|
103
|
+
it('groups terms with filtered counts and named links in the current register', async () => {
|
|
104
|
+
const wrapper = mountPane();
|
|
105
|
+
const sections = wrapper.findAll('section');
|
|
106
|
+
expect(sections).toHaveLength(2);
|
|
107
|
+
expect(sections[0].find('h3').text()).toContain('ISO 123:2024');
|
|
108
|
+
expect(sections[0].find('h3').text()).toContain('(2)');
|
|
109
|
+
expect(sections[1].find('h3').text()).toContain('IEC 456:2020');
|
|
110
|
+
expect(sections[1].find('h3').text()).toContain('(1)');
|
|
111
|
+
expect(wrapper.text()).not.toContain('Current term');
|
|
112
|
+
const links = wrapper.findAllComponents(RouterLink);
|
|
113
|
+
expect(links.map(link => link.props('to'))).toEqual([
|
|
114
|
+
{ name: 'concept', params: { registerId: 'test-register', conceptId: 'other' } },
|
|
115
|
+
{ name: 'concept', params: { registerId: 'test-register', conceptId: 'third' } },
|
|
116
|
+
{ name: 'concept', params: { registerId: 'test-register', conceptId: 'fourth' } },
|
|
117
|
+
]);
|
|
118
|
+
expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual([
|
|
119
|
+
'/dataset/test-register/concept/other',
|
|
120
|
+
'/dataset/test-register/concept/third',
|
|
121
|
+
'/dataset/test-register/concept/fourth',
|
|
122
|
+
]);
|
|
123
|
+
expect(links[0].text()).toBe('Related term');
|
|
124
|
+
await links[0].trigger('click');
|
|
125
|
+
await flushPromises();
|
|
126
|
+
expect(router.currentRoute.value.params).toEqual({ registerId: 'test-register', conceptId: 'other' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('renders nothing for missing groups or groups containing only the current concept', () => {
|
|
130
|
+
expect(mountPane([]).find('aside').exists()).toBe(false);
|
|
131
|
+
expect(mountPane([{ key: 'empty', label: 'Empty standard', terms: [] }]).find('aside').exists()).toBe(false);
|
|
132
|
+
expect(mountPane([{ ...groups[0], terms: [groups[0].terms[0]] }]).find('aside').exists()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('uses a native accessible toggle and persists collapsed state across remounts', async () => {
|
|
136
|
+
const wrapper = mountPane();
|
|
137
|
+
const button = wrapper.get('button');
|
|
138
|
+
expect(button.attributes('type')).toBe('button');
|
|
139
|
+
expect(button.attributes('aria-expanded')).toBe('true');
|
|
140
|
+
const target = wrapper.get(`#${button.attributes('aria-controls')}`);
|
|
141
|
+
expect(target.isVisible()).toBe(true);
|
|
142
|
+
await button.trigger('click');
|
|
143
|
+
expect(button.attributes('aria-expanded')).toBe('false');
|
|
144
|
+
expect(target.isVisible()).toBe(false);
|
|
145
|
+
wrapper.unmount();
|
|
146
|
+
|
|
147
|
+
const remounted = mountPane();
|
|
148
|
+
expect(remounted.get('button').attributes('aria-expanded')).toBe('false');
|
|
149
|
+
await remounted.get('button').trigger('click');
|
|
150
|
+
expect(remounted.get(`#${remounted.get('button').attributes('aria-controls')}`).isVisible()).toBe(true);
|
|
151
|
+
remounted.unmount();
|
|
152
|
+
expect(mountPane().get('button').attributes('aria-expanded')).toBe('true');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('gives each pane its own controlled region', () => {
|
|
156
|
+
const wrapper = mount({
|
|
157
|
+
components: { SameStandardPane },
|
|
158
|
+
setup: () => ({ groups }),
|
|
159
|
+
template: '<div><SameStandardPane :groups="groups" register-id="test" concept-id="current" /><SameStandardPane :groups="groups" register-id="test" concept-id="current" /></div>',
|
|
160
|
+
}, { global: { plugins: [pinia, router] } });
|
|
161
|
+
const [first, second] = wrapper.findAll('button').map(button => button.attributes('aria-controls'));
|
|
162
|
+
expect(first).toBeTruthy();
|
|
163
|
+
expect(second).not.toBe(first);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('continues toggling when storage reads or writes fail', async () => {
|
|
167
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Unavailable'); });
|
|
168
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Unavailable'); });
|
|
169
|
+
const wrapper = mountPane();
|
|
170
|
+
expect(wrapper.get('button').attributes('aria-expanded')).toBe('true');
|
|
171
|
+
await wrapper.get('button').trigger('click');
|
|
172
|
+
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
interface Chunk {
|
|
10
|
+
file: string;
|
|
11
|
+
count: number;
|
|
12
|
+
hash: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('generate-data chunk hashes', () => {
|
|
16
|
+
it('writes content hashes to both indexes and changes only the hash of modified chunks', () => {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'generate-data-hashes-'));
|
|
18
|
+
const conceptsDir = join(root, 'source', 'concepts');
|
|
19
|
+
const outputDir = join(root, 'public', 'data', 'fixture');
|
|
20
|
+
const configPath = join(root, 'site-config.yml');
|
|
21
|
+
const generator = resolve('scripts/generate-data.ts');
|
|
22
|
+
const tsx = resolve('node_modules/tsx/dist/loader.mjs');
|
|
23
|
+
|
|
24
|
+
const writeConcept = (id: number, designation: string) => {
|
|
25
|
+
writeFileSync(join(conceptsDir, `${id}.yaml`), JSON.stringify({
|
|
26
|
+
termid: String(id),
|
|
27
|
+
eng: { terms: [{ designation }], entry_status: 'valid' },
|
|
28
|
+
}));
|
|
29
|
+
};
|
|
30
|
+
const generate = () => {
|
|
31
|
+
execFileSync(process.execPath, ['--import', tsx, generator], {
|
|
32
|
+
cwd: root,
|
|
33
|
+
env: { ...process.env, SITE_CONFIG: configPath },
|
|
34
|
+
stdio: 'pipe',
|
|
35
|
+
timeout: 30_000,
|
|
36
|
+
});
|
|
37
|
+
const indexes = ['index.json', 'index-meta.json'].map(file =>
|
|
38
|
+
JSON.parse(readFileSync(join(outputDir, file), 'utf8')),
|
|
39
|
+
);
|
|
40
|
+
for (const index of indexes) {
|
|
41
|
+
expect(index.conceptCount).toBe(501);
|
|
42
|
+
expect(index.chunks.map((chunk: Chunk) => chunk.count)).toEqual([500, 1]);
|
|
43
|
+
for (const chunk of index.chunks as Chunk[]) {
|
|
44
|
+
expect(chunk.hash).toMatch(/^[a-f0-9]{12}$/);
|
|
45
|
+
const content = readFileSync(join(outputDir, 'chunks', chunk.file));
|
|
46
|
+
expect(chunk.hash).toBe(createHash('sha256').update(content).digest('hex').slice(0, 12));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
expect(indexes[1].chunks).toEqual(indexes[0].chunks);
|
|
50
|
+
return indexes[0].chunks as Chunk[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(conceptsDir, { recursive: true });
|
|
55
|
+
writeFileSync(configPath, JSON.stringify({
|
|
56
|
+
id: 'fixture', title: 'Fixture', uri_base: 'https://example.test',
|
|
57
|
+
datasets: [{ id: 'fixture', local_path: 'source', languages: ['eng'] }],
|
|
58
|
+
}));
|
|
59
|
+
for (let id = 1; id <= 501; id++) writeConcept(id, `Term ${id}`);
|
|
60
|
+
|
|
61
|
+
const initial = generate();
|
|
62
|
+
expect(generate()).toEqual(initial);
|
|
63
|
+
|
|
64
|
+
writeConcept(1, 'Updated term');
|
|
65
|
+
const changed = generate();
|
|
66
|
+
expect(changed[0]!.hash).not.toBe(initial[0]!.hash);
|
|
67
|
+
expect(changed[1]).toEqual(initial[1]);
|
|
68
|
+
const chunk = JSON.parse(readFileSync(join(outputDir, 'chunks', changed[0]!.file), 'utf8'));
|
|
69
|
+
expect(chunk.concepts[0].designations.eng).toBe('Updated term');
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}, 60_000);
|
|
74
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
4
|
+
import type { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
5
|
+
import type { Manifest, SearchHit } from '../adapters/types';
|
|
6
|
+
|
|
7
|
+
const discoverDatasets = vi.fn();
|
|
8
|
+
vi.mock('../adapters/factory', () => ({
|
|
9
|
+
getFactory: () => ({ discoverDatasets, getAdapters: () => [], loadDataset: vi.fn() }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
function adapter(registerId: string, indexRows: SearchHit[], laterRows: SearchHit[] = [], options: { failLoad?: boolean; manifest?: boolean } = {}) {
|
|
13
|
+
const index = { concepts: [...indexRows] };
|
|
14
|
+
const search = vi.fn((query: string) => index.concepts
|
|
15
|
+
.filter((row) => row.designation.toLowerCase().includes(query.toLowerCase())));
|
|
16
|
+
const ensureAllChunksLoaded = vi.fn(async () => {
|
|
17
|
+
await Promise.resolve();
|
|
18
|
+
if (options.failLoad) throw new Error('chunk unavailable');
|
|
19
|
+
index.concepts.push(...laterRows);
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
adapter: {
|
|
23
|
+
registerId,
|
|
24
|
+
manifest: options.manifest === false ? null : { id: registerId } as Manifest,
|
|
25
|
+
index,
|
|
26
|
+
loadIndex: vi.fn(async () => undefined),
|
|
27
|
+
search,
|
|
28
|
+
ensureAllChunksLoaded,
|
|
29
|
+
} as unknown as DatasetAdapter,
|
|
30
|
+
search,
|
|
31
|
+
ensureAllChunksLoaded,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hit = (registerId: string, conceptId: string, designation: string, status = 'valid'): SearchHit => ({
|
|
36
|
+
registerId, conceptId, designation, status, language: 'eng', matchField: 'designation',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('searchAcrossDatasets', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
setActivePinia(createPinia());
|
|
42
|
+
discoverDatasets.mockReset();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns both location concepts on first and repeated searches', async () => {
|
|
46
|
+
const large = adapter('large', [hit('large', '276', 'location', 'superseded'), hit('large', '999', 'place-name')], [hit('large', '2345', 'location'), hit('large', '1280', 'place')]);
|
|
47
|
+
const other = adapter('other', [], [hit('other', '1280', 'place')]);
|
|
48
|
+
discoverDatasets.mockResolvedValue([large.adapter, other.adapter]);
|
|
49
|
+
const store = useVocabularyStore();
|
|
50
|
+
const locationHits = await store.searchAcrossDatasets('location');
|
|
51
|
+
expect(locationHits.map(({ conceptId, status }) => [conceptId, status])).toEqual([['276', 'superseded'], ['2345', 'valid']]);
|
|
52
|
+
const repeatedLocationHits = await store.searchAcrossDatasets('location');
|
|
53
|
+
expect(repeatedLocationHits.map(({ conceptId, status }) => [conceptId, status])).toEqual([['276', 'superseded'], ['2345', 'valid']]);
|
|
54
|
+
expect(large.ensureAllChunksLoaded).toHaveBeenCalledTimes(2);
|
|
55
|
+
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('finds later place concepts even when chunk zero has a matching term', async () => {
|
|
59
|
+
const freshLarge = adapter('large', [hit('large', '999', 'place-name')], [hit('large', '1280', 'place')]);
|
|
60
|
+
const freshOther = adapter('other', [], [hit('other', '1280', 'place')]);
|
|
61
|
+
discoverDatasets.mockResolvedValue([freshLarge.adapter, freshOther.adapter]);
|
|
62
|
+
const placeHits = await useVocabularyStore().searchAcrossDatasets('place');
|
|
63
|
+
expect(placeHits.map(({ registerId, conceptId }) => [registerId, conceptId])).toEqual([['large', '999'], ['large', '1280'], ['other', '1280']]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('preserves loaded hits when a later chunk fails and continues searching other datasets', async () => {
|
|
67
|
+
const loadedHit = hit('partial', '276', 'location', 'superseded');
|
|
68
|
+
const partial = adapter('partial', [loadedHit], [], { failLoad: true });
|
|
69
|
+
const healthyHit = hit('healthy', '2345', 'location');
|
|
70
|
+
const healthy = adapter('healthy', [], [healthyHit]);
|
|
71
|
+
discoverDatasets.mockResolvedValue([partial.adapter, healthy.adapter]);
|
|
72
|
+
|
|
73
|
+
const store = useVocabularyStore();
|
|
74
|
+
const results = await store.searchAcrossDatasets('location');
|
|
75
|
+
|
|
76
|
+
expect(partial.ensureAllChunksLoaded).toHaveBeenCalledOnce();
|
|
77
|
+
expect(results).toEqual([loadedHit, healthyHit]);
|
|
78
|
+
expect(store.searchWarning).toBe('search.incomplete');
|
|
79
|
+
|
|
80
|
+
partial.ensureAllChunksLoaded.mockResolvedValue(undefined);
|
|
81
|
+
await store.searchAcrossDatasets('location');
|
|
82
|
+
expect(store.searchWarning).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('continues after more than 20 hits and isolates chunk load and search failures', async () => {
|
|
86
|
+
const crowdedRows = Array.from({ length: 21 }, (_, i) => hit('crowded', `needle-${i}`, 'needle'));
|
|
87
|
+
const crowded = adapter('crowded', crowdedRows);
|
|
88
|
+
const late = adapter('late', [], [hit('late', 'late-1', 'needle')]);
|
|
89
|
+
const brokenLoad = adapter('broken-load', [], [], { failLoad: true });
|
|
90
|
+
const noManifest = adapter('no-manifest', [], [hit('no-manifest', 'skip', 'needle')], { manifest: false });
|
|
91
|
+
const brokenSearch = adapter('broken-search', []);
|
|
92
|
+
brokenSearch.search.mockImplementation(() => { throw new Error('search failed'); });
|
|
93
|
+
discoverDatasets.mockResolvedValue([crowded.adapter, brokenLoad.adapter, noManifest.adapter, brokenSearch.adapter, late.adapter]);
|
|
94
|
+
|
|
95
|
+
const results = await useVocabularyStore().searchAcrossDatasets('needle');
|
|
96
|
+
expect(results.map(({ registerId, conceptId }) => [registerId, conceptId])).toContainEqual(['late', 'late-1']);
|
|
97
|
+
expect(results).toHaveLength(22);
|
|
98
|
+
expect(late.ensureAllChunksLoaded).toHaveBeenCalledOnce();
|
|
99
|
+
expect(brokenLoad.ensureAllChunksLoaded).toHaveBeenCalledOnce();
|
|
100
|
+
expect(noManifest.ensureAllChunksLoaded).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|