@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
|
@@ -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
|
});
|