@glossarist/concept-browser 0.7.139 → 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/generate-data.js +51 -3
- package/scripts/generate-data.ts +37 -3
- 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 +150 -0
- 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__/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/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
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { effectScope, nextTick, shallowReactive, type EffectScope } from 'vue';
|
|
3
|
+
import { Concept } from 'glossarist';
|
|
4
|
+
import { DatasetAdapter } from '../../adapters/DatasetAdapter';
|
|
5
|
+
import type { ConceptSummary } from '../../adapters/types';
|
|
6
|
+
import { useSameStandardTerms } from '../../composables/use-same-standard-terms';
|
|
7
|
+
|
|
8
|
+
const stores = vi.hoisted(() => ({ vocabulary: null as any, ui: null as any }));
|
|
9
|
+
vi.mock('../../stores/vocabulary', () => ({ useVocabularyStore: () => stores.vocabulary }));
|
|
10
|
+
vi.mock('../../stores/ui', () => ({ useUiStore: () => stores.ui }));
|
|
11
|
+
|
|
12
|
+
const standard = (id: string, type = 'authoritative') => ({
|
|
13
|
+
type,
|
|
14
|
+
origin: { ref: { source: 'ISO', id } },
|
|
15
|
+
});
|
|
16
|
+
const concept = (id: string, standards = ['704:2022']) => Concept.fromJSON({
|
|
17
|
+
id,
|
|
18
|
+
sources: standards.map(id => standard(id)),
|
|
19
|
+
});
|
|
20
|
+
const index = {
|
|
21
|
+
'iso 704:2022': { label: 'ISO 704:2022', conceptIds: ['1', '2', '3', '4'] },
|
|
22
|
+
'iso 1087:2019': { label: 'ISO 1087:2019', conceptIds: ['1', '3'] },
|
|
23
|
+
'iso 999:2020': { label: 'ISO 999:2020', conceptIds: ['5'] },
|
|
24
|
+
};
|
|
25
|
+
const response = (body: unknown) => ({ ok: true, json: async () => body }) as Response;
|
|
26
|
+
|
|
27
|
+
async function settle() {
|
|
28
|
+
await nextTick();
|
|
29
|
+
await Promise.resolve();
|
|
30
|
+
await nextTick();
|
|
31
|
+
await Promise.resolve();
|
|
32
|
+
await nextTick();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useSameStandardTerms', () => {
|
|
36
|
+
let registerNumber = 0;
|
|
37
|
+
let scopes: EffectScope[];
|
|
38
|
+
let summaries: Map<string, ConceptSummary>;
|
|
39
|
+
|
|
40
|
+
function useTerms(baseUrl?: string) {
|
|
41
|
+
const scope = effectScope();
|
|
42
|
+
scopes.push(scope);
|
|
43
|
+
return scope.run(() => useSameStandardTerms(baseUrl))!;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
scopes = [];
|
|
48
|
+
summaries = new Map([
|
|
49
|
+
['1', { id: '1', designations: { fra: 'courant' }, eng: 'current', status: 'valid' }],
|
|
50
|
+
['2', { id: '2', designations: { fra: 'terme', eng: 'term' }, eng: 'term', status: 'valid' }],
|
|
51
|
+
['3', { id: '3', designations: { deu: 'Begriff', eng: 'concept' }, eng: 'concept', status: 'valid' }],
|
|
52
|
+
['4', { id: '4', designations: {}, eng: '', status: 'valid' }],
|
|
53
|
+
['5', { id: '5', designations: {}, eng: 'unrelated', status: 'valid' }],
|
|
54
|
+
]);
|
|
55
|
+
stores.vocabulary = shallowReactive({
|
|
56
|
+
currentRegisterId: `same-standard-${++registerNumber}`,
|
|
57
|
+
currentConceptId: '1',
|
|
58
|
+
currentConcept: concept('1'),
|
|
59
|
+
currentAdapter: {
|
|
60
|
+
getIndexEntry: (id: string) => summaries.get(id),
|
|
61
|
+
ensureAllChunksLoaded: vi.fn(async () => {}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
stores.ui = shallowReactive({ selectedLang: 'fra' });
|
|
65
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response(index)));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
scopes.forEach(scope => scope.stop());
|
|
70
|
+
vi.unstubAllGlobals();
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns groups directly, gathering concept and localized authoritative sources and excluding the current concept', async () => {
|
|
75
|
+
stores.vocabulary.currentConcept = Concept.fromJSON({
|
|
76
|
+
id: '1',
|
|
77
|
+
sources: [standard('704:2022'), standard('999:2020', 'lineage')],
|
|
78
|
+
localizations: {
|
|
79
|
+
eng: { sources: [standard('704:2022'), standard('1087:2019')] },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
const groups = useTerms('/browser/');
|
|
83
|
+
expect(groups.value).toEqual([]);
|
|
84
|
+
await settle();
|
|
85
|
+
|
|
86
|
+
expect(fetch).toHaveBeenCalledWith(`/browser/data/${stores.vocabulary.currentRegisterId}/standards.json`);
|
|
87
|
+
expect(groups.value).toEqual([
|
|
88
|
+
{ key: 'iso 704:2022', label: 'ISO 704:2022', terms: [
|
|
89
|
+
{ conceptId: '2', designation: 'terme' },
|
|
90
|
+
{ conceptId: '3', designation: 'concept' },
|
|
91
|
+
{ conceptId: '4', designation: '4' },
|
|
92
|
+
] },
|
|
93
|
+
{ key: 'iso 1087:2019', label: 'ISO 1087:2019', terms: [
|
|
94
|
+
{ conceptId: '3', designation: 'concept' },
|
|
95
|
+
] },
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('loads summaries from later index chunks without fetching concept payloads', async () => {
|
|
100
|
+
const adapter = new DatasetAdapter('chunked-standard', '/data/chunked-standard');
|
|
101
|
+
adapter.manifest = { conceptCount: 1200 } as any;
|
|
102
|
+
const chunks = Array.from({ length: 3 }, (_, chunk) => ({
|
|
103
|
+
file: `index-${chunk}.json`, count: chunk === 2 ? 200 : 500,
|
|
104
|
+
}));
|
|
105
|
+
const ids = ['1', '2', '600', '1100'];
|
|
106
|
+
const chunkConcepts = (chunk: number) => Array.from({ length: chunks[chunk].count }, (_, offset) => {
|
|
107
|
+
const id = String(chunk * 500 + offset + 1);
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
designations: ids.includes(id) ? { eng: `term number ${id}` } : {},
|
|
111
|
+
status: 'valid',
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
vi.mocked(fetch).mockImplementation(async input => {
|
|
115
|
+
const url = String(input);
|
|
116
|
+
if (url.endsWith('/standards.json')) return response({
|
|
117
|
+
'iso 19165-2:2020': { label: 'ISO 19165-2:2020', conceptIds: ids },
|
|
118
|
+
});
|
|
119
|
+
if (url.endsWith('/index-meta.json')) return response({
|
|
120
|
+
registerId: 'chunked-standard', schemaVersion: '1', conceptCount: 1200,
|
|
121
|
+
chunkSize: 500, chunks,
|
|
122
|
+
});
|
|
123
|
+
const chunk = Number(url.match(/index-(\d)\.json$/)?.[1]);
|
|
124
|
+
if (Number.isInteger(chunk)) return response({ concepts: chunkConcepts(chunk) });
|
|
125
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
126
|
+
});
|
|
127
|
+
await adapter.loadIndex();
|
|
128
|
+
expect(adapter.getIndexEntry('2')).toBeDefined();
|
|
129
|
+
expect(adapter.getIndexEntry('600')).toBeUndefined();
|
|
130
|
+
|
|
131
|
+
stores.vocabulary.currentRegisterId = 'chunked-standard';
|
|
132
|
+
stores.vocabulary.currentConceptId = '1';
|
|
133
|
+
stores.vocabulary.currentConcept = Concept.fromJSON({
|
|
134
|
+
id: '1', localizations: { eng: { sources: [standard('19165-2:2020')] } },
|
|
135
|
+
});
|
|
136
|
+
stores.vocabulary.currentAdapter = adapter;
|
|
137
|
+
const groups = useTerms('/');
|
|
138
|
+
await vi.waitFor(() => expect(groups.value[0]?.terms.map(term => term.designation)).toEqual([
|
|
139
|
+
'term number 2', 'term number 600', 'term number 1100',
|
|
140
|
+
]));
|
|
141
|
+
|
|
142
|
+
expect(groups.value).toEqual([{
|
|
143
|
+
key: 'iso 19165-2:2020', label: 'ISO 19165-2:2020', terms: [
|
|
144
|
+
{ conceptId: '2', designation: 'term number 2' },
|
|
145
|
+
{ conceptId: '600', designation: 'term number 600' },
|
|
146
|
+
{ conceptId: '1100', designation: 'term number 1100' },
|
|
147
|
+
],
|
|
148
|
+
}]);
|
|
149
|
+
stores.vocabulary.currentConceptId = '600';
|
|
150
|
+
stores.vocabulary.currentConcept = Concept.fromJSON({
|
|
151
|
+
id: '600', localizations: { eng: { sources: [standard('19165-2:2020')] } },
|
|
152
|
+
});
|
|
153
|
+
await settle();
|
|
154
|
+
expect(groups.value[0].terms).toEqual([
|
|
155
|
+
{ conceptId: '1', designation: 'term number 1' },
|
|
156
|
+
{ conceptId: '2', designation: 'term number 2' },
|
|
157
|
+
{ conceptId: '1100', designation: 'term number 1100' },
|
|
158
|
+
]);
|
|
159
|
+
expect(fetch).not.toHaveBeenCalledWith(expect.stringContaining('/concepts/'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('keeps ID fallbacks after summary loading fails and retries from a later instance', async () => {
|
|
163
|
+
const loaded = new Map<string, ConceptSummary>([
|
|
164
|
+
['3', summaries.get('3')!],
|
|
165
|
+
['4', summaries.get('4')!],
|
|
166
|
+
]);
|
|
167
|
+
const ensureAllChunksLoaded = vi.fn()
|
|
168
|
+
.mockRejectedValueOnce(new Error('chunk unavailable'))
|
|
169
|
+
.mockImplementationOnce(async () => {
|
|
170
|
+
loaded.set('2', { id: '2', designations: { eng: 'retried term' }, eng: 'retried term', status: 'valid' });
|
|
171
|
+
});
|
|
172
|
+
stores.vocabulary.currentAdapter = {
|
|
173
|
+
getIndexEntry: (id: string) => loaded.get(id),
|
|
174
|
+
ensureAllChunksLoaded,
|
|
175
|
+
};
|
|
176
|
+
const first = useTerms('/');
|
|
177
|
+
await vi.waitFor(() => expect(ensureAllChunksLoaded).toHaveBeenCalledTimes(1));
|
|
178
|
+
await vi.waitFor(() => expect(first.value[0]?.terms).toEqual([
|
|
179
|
+
{ conceptId: '2', designation: '2' },
|
|
180
|
+
{ conceptId: '3', designation: 'concept' },
|
|
181
|
+
{ conceptId: '4', designation: '4' },
|
|
182
|
+
]));
|
|
183
|
+
|
|
184
|
+
const second = useTerms('/');
|
|
185
|
+
await vi.waitFor(() => expect(ensureAllChunksLoaded).toHaveBeenCalledTimes(2));
|
|
186
|
+
await vi.waitFor(() => expect(second.value[0]?.terms[0]).toEqual({
|
|
187
|
+
conceptId: '2', designation: 'retried term',
|
|
188
|
+
}));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('shares deferred summary loading and ignores its completion after register navigation', async () => {
|
|
192
|
+
let resolveOld!: () => void;
|
|
193
|
+
const oldSummaries = new Map<string, ConceptSummary>();
|
|
194
|
+
const oldAdapter = {
|
|
195
|
+
getIndexEntry: (id: string) => oldSummaries.get(id),
|
|
196
|
+
ensureAllChunksLoaded: vi.fn(() => new Promise<void>(resolve => { resolveOld = resolve; })),
|
|
197
|
+
};
|
|
198
|
+
stores.vocabulary.currentAdapter = oldAdapter;
|
|
199
|
+
vi.mocked(fetch).mockImplementation(async input => {
|
|
200
|
+
const url = String(input);
|
|
201
|
+
if (url.endsWith('/standards.json') && url.includes('/old-register/')) return response({
|
|
202
|
+
'iso 704:2022': { label: 'Old standard', conceptIds: ['1', '2'] },
|
|
203
|
+
});
|
|
204
|
+
if (url.endsWith('/standards.json')) return response({
|
|
205
|
+
'iso 704:2022': { label: 'New standard', conceptIds: ['1', '5'] },
|
|
206
|
+
});
|
|
207
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
208
|
+
});
|
|
209
|
+
stores.vocabulary.currentRegisterId = 'old-register';
|
|
210
|
+
const first = useTerms('/');
|
|
211
|
+
const second = useTerms('/');
|
|
212
|
+
await vi.waitFor(() => expect(oldAdapter.ensureAllChunksLoaded).toHaveBeenCalledTimes(1));
|
|
213
|
+
|
|
214
|
+
stores.vocabulary.currentRegisterId = 'new-register';
|
|
215
|
+
const newSummaries = new Map<string, ConceptSummary>([
|
|
216
|
+
['1', { id: '1', designations: { eng: 'new current' }, eng: 'new current', status: 'valid' }],
|
|
217
|
+
['5', { id: '5', designations: { eng: 'new related' }, eng: 'new related', status: 'valid' }],
|
|
218
|
+
]);
|
|
219
|
+
stores.vocabulary.currentAdapter = {
|
|
220
|
+
getIndexEntry: (id: string) => newSummaries.get(id),
|
|
221
|
+
ensureAllChunksLoaded: vi.fn(async () => {}),
|
|
222
|
+
};
|
|
223
|
+
stores.vocabulary.currentConcept = concept('1');
|
|
224
|
+
const expected = [{
|
|
225
|
+
key: 'iso 704:2022', label: 'New standard',
|
|
226
|
+
terms: [{ conceptId: '5', designation: 'new related' }],
|
|
227
|
+
}];
|
|
228
|
+
await vi.waitFor(() => {
|
|
229
|
+
expect(first.value).toEqual(expected);
|
|
230
|
+
expect(second.value).toEqual(expected);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
oldSummaries.set('2', { id: '2', designations: { eng: 'stale related' }, eng: 'stale related', status: 'valid' });
|
|
234
|
+
resolveOld();
|
|
235
|
+
await settle();
|
|
236
|
+
expect(first.value).toEqual(expected);
|
|
237
|
+
expect(second.value).toEqual(expected);
|
|
238
|
+
expect(oldAdapter.ensureAllChunksLoaded).toHaveBeenCalledTimes(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('reacts to selected language using English and then the concept ID as fallbacks', async () => {
|
|
242
|
+
const groups = useTerms('/');
|
|
243
|
+
await settle();
|
|
244
|
+
expect(groups.value[0].terms.map(term => term.designation)).toEqual(['terme', 'concept', '4']);
|
|
245
|
+
|
|
246
|
+
stores.ui.selectedLang = 'deu';
|
|
247
|
+
await nextTick();
|
|
248
|
+
expect(groups.value[0].terms.map(term => term.designation)).toEqual(['term', 'Begriff', '4']);
|
|
249
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('uses the configured default base URL when called without arguments', async () => {
|
|
253
|
+
useTerms();
|
|
254
|
+
await settle();
|
|
255
|
+
expect(fetch).toHaveBeenCalledWith(`${import.meta.env.BASE_URL}data/${stores.vocabulary.currentRegisterId}/standards.json`);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it.each(['404', 'network rejection'])('returns no groups for %s', async failure => {
|
|
259
|
+
if (failure === '404') {
|
|
260
|
+
vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404 } as Response);
|
|
261
|
+
} else {
|
|
262
|
+
vi.mocked(fetch).mockRejectedValue(new Error('offline'));
|
|
263
|
+
}
|
|
264
|
+
const groups = useTerms('/');
|
|
265
|
+
await settle();
|
|
266
|
+
expect(groups.value).toEqual([]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('shares one in-flight request across instances and reuses it during concept navigation', async () => {
|
|
270
|
+
let resolve!: (value: Response) => void;
|
|
271
|
+
vi.mocked(fetch).mockReturnValue(new Promise(done => { resolve = done; }));
|
|
272
|
+
const first = useTerms('/');
|
|
273
|
+
const second = useTerms('/');
|
|
274
|
+
await nextTick();
|
|
275
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
276
|
+
|
|
277
|
+
resolve(response(index));
|
|
278
|
+
await settle();
|
|
279
|
+
expect(second.value).toEqual(first.value);
|
|
280
|
+
expect(first.value[0].terms).toHaveLength(3);
|
|
281
|
+
|
|
282
|
+
stores.vocabulary.currentConceptId = '3';
|
|
283
|
+
stores.vocabulary.currentConcept = concept('3', ['1087:2019']);
|
|
284
|
+
await settle();
|
|
285
|
+
expect(first.value).toEqual([
|
|
286
|
+
{ key: 'iso 1087:2019', label: 'ISO 1087:2019', terms: [{ conceptId: '1', designation: 'courant' }] },
|
|
287
|
+
]);
|
|
288
|
+
expect(second.value).toEqual(first.value);
|
|
289
|
+
const later = useTerms('/');
|
|
290
|
+
await settle();
|
|
291
|
+
expect(later.value).toEqual(first.value);
|
|
292
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('retries a rejected request on a later instance and caches the successful response', async () => {
|
|
296
|
+
vi.mocked(fetch)
|
|
297
|
+
.mockRejectedValueOnce(new Error('temporarily offline'))
|
|
298
|
+
.mockResolvedValueOnce(response(index));
|
|
299
|
+
const first = useTerms('/');
|
|
300
|
+
await settle();
|
|
301
|
+
expect(first.value).toEqual([]);
|
|
302
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
303
|
+
|
|
304
|
+
const second = useTerms('/');
|
|
305
|
+
await settle();
|
|
306
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
307
|
+
expect(second.value).toEqual([{
|
|
308
|
+
key: 'iso 704:2022', label: 'ISO 704:2022', terms: [
|
|
309
|
+
{ conceptId: '2', designation: 'terme' },
|
|
310
|
+
{ conceptId: '3', designation: 'concept' },
|
|
311
|
+
{ conceptId: '4', designation: '4' },
|
|
312
|
+
],
|
|
313
|
+
}]);
|
|
314
|
+
|
|
315
|
+
const third = useTerms('/');
|
|
316
|
+
await settle();
|
|
317
|
+
expect(third.value).toEqual(second.value);
|
|
318
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('waits for the new register concept even when its ID matches the previous concept', async () => {
|
|
322
|
+
vi.mocked(fetch)
|
|
323
|
+
.mockResolvedValueOnce(response(index))
|
|
324
|
+
.mockResolvedValueOnce(response({
|
|
325
|
+
'iso 704:2022': { label: 'New register standard', conceptIds: ['1', '9'] },
|
|
326
|
+
}));
|
|
327
|
+
const groups = useTerms('/');
|
|
328
|
+
await settle();
|
|
329
|
+
expect(groups.value[0].terms).toHaveLength(3);
|
|
330
|
+
|
|
331
|
+
stores.vocabulary.currentRegisterId = `same-standard-other-${registerNumber}`;
|
|
332
|
+
const newSummaries = new Map<string, ConceptSummary>([
|
|
333
|
+
['9', { id: '9', designations: { fra: 'nouveau terme' }, eng: 'new term', status: 'valid' }],
|
|
334
|
+
]);
|
|
335
|
+
stores.vocabulary.currentAdapter = {
|
|
336
|
+
getIndexEntry: (id: string) => newSummaries.get(id),
|
|
337
|
+
ensureAllChunksLoaded: vi.fn(async () => {}),
|
|
338
|
+
};
|
|
339
|
+
expect(groups.value).toEqual([]);
|
|
340
|
+
await settle();
|
|
341
|
+
expect(groups.value).toEqual([]);
|
|
342
|
+
|
|
343
|
+
stores.vocabulary.currentConcept = concept('1');
|
|
344
|
+
await settle();
|
|
345
|
+
expect(groups.value).toEqual([{
|
|
346
|
+
key: 'iso 704:2022', label: 'New register standard',
|
|
347
|
+
terms: [{ conceptId: '9', designation: 'nouveau terme' }],
|
|
348
|
+
}]);
|
|
349
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('keys the cache by full URL, including the base path', async () => {
|
|
353
|
+
useTerms('/first/');
|
|
354
|
+
useTerms('/second/');
|
|
355
|
+
await settle();
|
|
356
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
357
|
+
expect(fetch).toHaveBeenCalledWith(`/first/data/${stores.vocabulary.currentRegisterId}/standards.json`);
|
|
358
|
+
expect(fetch).toHaveBeenCalledWith(`/second/data/${stores.vocabulary.currentRegisterId}/standards.json`);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('clears groups when navigating to a concept without authoritative standards', async () => {
|
|
362
|
+
const groups = useTerms('/');
|
|
363
|
+
await settle();
|
|
364
|
+
expect(groups.value).toHaveLength(1);
|
|
365
|
+
|
|
366
|
+
stores.vocabulary.currentConceptId = '5';
|
|
367
|
+
stores.vocabulary.currentConcept = concept('5', []);
|
|
368
|
+
await settle();
|
|
369
|
+
expect(groups.value).toEqual([]);
|
|
370
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('ignores a stale register response after navigation', async () => {
|
|
374
|
+
let resolveOld!: (value: Response) => void;
|
|
375
|
+
vi.mocked(fetch)
|
|
376
|
+
.mockImplementationOnce(() => new Promise(done => { resolveOld = done; }))
|
|
377
|
+
.mockResolvedValueOnce(response({
|
|
378
|
+
'iso 704:2022': { label: 'New register standard', conceptIds: ['1', '5'] },
|
|
379
|
+
}));
|
|
380
|
+
const groups = useTerms('/');
|
|
381
|
+
await nextTick();
|
|
382
|
+
stores.vocabulary.currentRegisterId = `same-standard-new-${registerNumber}`;
|
|
383
|
+
await settle();
|
|
384
|
+
stores.vocabulary.currentConcept = concept('1');
|
|
385
|
+
await settle();
|
|
386
|
+
const expected = [{
|
|
387
|
+
key: 'iso 704:2022', label: 'New register standard',
|
|
388
|
+
terms: [{ conceptId: '5', designation: '5' }],
|
|
389
|
+
}];
|
|
390
|
+
expect(groups.value).toEqual(expected);
|
|
391
|
+
|
|
392
|
+
resolveOld(response(index));
|
|
393
|
+
await settle();
|
|
394
|
+
expect(groups.value).toEqual(expected);
|
|
395
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -4,6 +4,8 @@ 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
|
+
import { ChunkLoadError } from '../errors';
|
|
8
|
+
import { Concept } from 'glossarist';
|
|
7
9
|
|
|
8
10
|
// Mock fetch for cross-ref-index.json requests from ensureEdgesForDataset
|
|
9
11
|
const mockFetch = vi.fn();
|
|
@@ -91,4 +93,105 @@ describe('ConceptView', () => {
|
|
|
91
93
|
await flushPromises();
|
|
92
94
|
expect(wrapper.text()).toContain('Failed to load concept');
|
|
93
95
|
});
|
|
96
|
+
|
|
97
|
+
it('shows same-standard terms only in detail mode and clears them for an unrelated concept', async () => {
|
|
98
|
+
const registerId = 'same-standard-view';
|
|
99
|
+
const concept = Concept.fromJSON({
|
|
100
|
+
id: '1',
|
|
101
|
+
sources: [{ type: 'authoritative', origin: { ref: { source: 'ISO', id: '704:2022' } } }],
|
|
102
|
+
});
|
|
103
|
+
const store = useVocabularyStore();
|
|
104
|
+
store.manifests.set(registerId, makeManifest({ id: registerId }));
|
|
105
|
+
store.datasets.set(registerId, makeAdapterStub({
|
|
106
|
+
fetchConcept: async () => concept,
|
|
107
|
+
getIndexEntry: () => ({ designations: { eng: 'Related term' } }),
|
|
108
|
+
}));
|
|
109
|
+
mockFetch.mockImplementation(async (url: string) => url.endsWith('/standards.json')
|
|
110
|
+
? { ok: true, json: async () => ({
|
|
111
|
+
'iso 704:2022': { label: 'ISO 704:2022', conceptIds: ['1', '2'] },
|
|
112
|
+
'iso 999:2020': { label: 'ISO 999:2020', conceptIds: ['4'] },
|
|
113
|
+
}) }
|
|
114
|
+
: { ok: false, status: 404 });
|
|
115
|
+
const wrapper = mount(ConceptView, {
|
|
116
|
+
global: { plugins: [pinia, router], stubs: { ConceptDetail: true, RelationSphere: true } },
|
|
117
|
+
props: { registerId, conceptId: '1' },
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
await flushPromises();
|
|
121
|
+
expect(mockFetch).toHaveBeenCalledWith(`${import.meta.env.BASE_URL}data/${registerId}/standards.json`);
|
|
122
|
+
expect(wrapper.find('aside').text()).toContain('ISO 704:2022');
|
|
123
|
+
expect(wrapper.find('aside a').attributes('href')).toBe(`/dataset/${registerId}/concept/2`);
|
|
124
|
+
expect(wrapper.find(`aside a[href="/dataset/${registerId}/concept/1"]`).exists()).toBe(false);
|
|
125
|
+
|
|
126
|
+
await wrapper.get('button[title="Relation sphere view (s)"]').trigger('click');
|
|
127
|
+
expect(wrapper.find('aside').exists()).toBe(false);
|
|
128
|
+
expect(wrapper.findComponent({ name: 'RelationSphere' }).exists()).toBe(true);
|
|
129
|
+
|
|
130
|
+
await wrapper.get('button[title="Detail view (d)"]').trigger('click');
|
|
131
|
+
expect(wrapper.find('aside').exists()).toBe(true);
|
|
132
|
+
expect(wrapper.findComponent({ name: 'ConceptDetail' }).exists()).toBe(true);
|
|
133
|
+
|
|
134
|
+
store.currentConcept = Concept.fromJSON({
|
|
135
|
+
id: '4',
|
|
136
|
+
sources: [{ type: 'authoritative', origin: { ref: { source: 'ISO', id: '999:2020' } } }],
|
|
137
|
+
});
|
|
138
|
+
await flushPromises();
|
|
139
|
+
expect(wrapper.find('aside').exists()).toBe(false);
|
|
140
|
+
expect(wrapper.get('.detail-layout').classes()).not.toContain('has-standard-pane');
|
|
141
|
+
|
|
142
|
+
store.currentConcept = Concept.fromJSON({ id: '3' });
|
|
143
|
+
await flushPromises();
|
|
144
|
+
expect(wrapper.find('aside').exists()).toBe(false);
|
|
145
|
+
expect(wrapper.findComponent({ name: 'ConceptDetail' }).exists()).toBe(true);
|
|
146
|
+
} finally {
|
|
147
|
+
wrapper.unmount();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('keeps the concept visible and removes stale navigation when adjacent chunks fail', async () => {
|
|
152
|
+
const concept = conceptFromJson({
|
|
153
|
+
'@id': 'https://glossarist.org/test/concept/1',
|
|
154
|
+
'@type': 'gl:Concept',
|
|
155
|
+
'gl:identifier': '1',
|
|
156
|
+
'gl:localizedConcept': {
|
|
157
|
+
eng: {
|
|
158
|
+
'@type': 'gl:LocalizedConcept',
|
|
159
|
+
'gl:languageCode': 'eng',
|
|
160
|
+
'gl:entryStatus': 'valid',
|
|
161
|
+
'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'test term', 'gl:normativeStatus': 'preferred' }],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const error = ChunkLoadError.make('test', 1, 503);
|
|
166
|
+
const ensureChunks = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(error);
|
|
167
|
+
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
168
|
+
useVocabularyStore().datasets.set('test', makeAdapterStub({
|
|
169
|
+
fetchConcept: async () => concept,
|
|
170
|
+
getConceptPosition: () => 1,
|
|
171
|
+
getAdjacentConcepts: () => ({ prev: '0', next: '2' }),
|
|
172
|
+
ensureChunksForRange: ensureChunks,
|
|
173
|
+
}));
|
|
174
|
+
const wrapper = mount(ConceptView, {
|
|
175
|
+
global: { plugins: [pinia, router] },
|
|
176
|
+
props: { registerId: 'test', conceptId: '1' },
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
await flushPromises();
|
|
180
|
+
expect(wrapper.find('h1').text()).toBe('test term');
|
|
181
|
+
expect(wrapper.find('button[title="Previous concept (←)"]').exists()).toBe(true);
|
|
182
|
+
expect(wrapper.find('button[title="Next concept (→)"]').exists()).toBe(true);
|
|
183
|
+
|
|
184
|
+
await wrapper.setProps({ conceptId: '2' });
|
|
185
|
+
await flushPromises();
|
|
186
|
+
|
|
187
|
+
expect(wrapper.find('h1').text()).toBe('test term');
|
|
188
|
+
expect(wrapper.find('button[title="Previous concept (←)"]').exists()).toBe(false);
|
|
189
|
+
expect(wrapper.find('button[title="Next concept (→)"]').exists()).toBe(false);
|
|
190
|
+
expect(wrapper.text()).not.toContain('Failed to load concept');
|
|
191
|
+
expect(log).toHaveBeenCalledWith('Failed to load adjacent concepts:', error);
|
|
192
|
+
} finally {
|
|
193
|
+
wrapper.unmount();
|
|
194
|
+
log.mockRestore();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
94
197
|
});
|
|
@@ -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',
|