@glossarist/concept-browser 0.7.139 → 0.7.141
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 +1 -23
- package/scripts/generate-data.js +206 -25
- package/scripts/generate-data.ts +235 -34
- package/scripts/lib/workers/emit-concept-worker.mjs +31 -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 +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 +78 -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
|
@@ -5,8 +5,12 @@ import { createRouter, createMemoryHistory } from 'vue-router';
|
|
|
5
5
|
import SearchBar from '../components/SearchBar.vue';
|
|
6
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
7
|
import { useUiStore } from '../stores/ui';
|
|
8
|
+
import { entryStatusColor, entryStatusLabel } from '../utils/concept-helpers';
|
|
8
9
|
import type { Manifest, SearchHit } from '../adapters/types';
|
|
9
10
|
|
|
11
|
+
const siteConfig = vi.hoisted(() => ({ value: null as null | { defaults: { language?: string; languageOrder?: string[] } } }));
|
|
12
|
+
vi.mock('../config/use-site-config', () => ({ useSiteConfig: () => ({ config: siteConfig }) }));
|
|
13
|
+
|
|
10
14
|
function makeManifest(): Manifest {
|
|
11
15
|
return {
|
|
12
16
|
id: 'test',
|
|
@@ -59,6 +63,8 @@ describe('SearchBar', () => {
|
|
|
59
63
|
let store: ReturnType<typeof useVocabularyStore>;
|
|
60
64
|
|
|
61
65
|
beforeEach(async () => {
|
|
66
|
+
localStorage.removeItem('searchSort');
|
|
67
|
+
siteConfig.value = null;
|
|
62
68
|
pinia = createPinia();
|
|
63
69
|
setActivePinia(pinia);
|
|
64
70
|
router = await createTestRouter();
|
|
@@ -108,6 +114,88 @@ describe('SearchBar', () => {
|
|
|
108
114
|
}
|
|
109
115
|
});
|
|
110
116
|
|
|
117
|
+
it('clears shared search state and does not restore it after remount', async () => {
|
|
118
|
+
const hits: SearchHit[] = [makeHit({ designation: 'road network' })];
|
|
119
|
+
store.searchAcrossDatasets = vi.fn(async () => hits);
|
|
120
|
+
const ui = useUiStore();
|
|
121
|
+
const wrapper = mountSearch();
|
|
122
|
+
await wrapper.find('input').setValue('road');
|
|
123
|
+
await wrapper.find('form').trigger('submit');
|
|
124
|
+
await flushPromises();
|
|
125
|
+
|
|
126
|
+
expect(ui.searchQuery).toBe('road');
|
|
127
|
+
expect(wrapper.text()).toContain('road network');
|
|
128
|
+
expect(router.currentRoute.value.query.q).toBe('road');
|
|
129
|
+
|
|
130
|
+
const clearBtn = wrapper.findAll('button').find(b => b.find('svg').exists() && b.element.closest('.relative') !== null);
|
|
131
|
+
expect(clearBtn).toBeDefined();
|
|
132
|
+
await clearBtn!.trigger('click');
|
|
133
|
+
await flushPromises();
|
|
134
|
+
|
|
135
|
+
expect(wrapper.find('input').element.value).toBe('');
|
|
136
|
+
expect(ui.searchQuery).toBe('');
|
|
137
|
+
expect(wrapper.text()).not.toContain('road network');
|
|
138
|
+
expect(router.currentRoute.value.query.q).toBeUndefined();
|
|
139
|
+
|
|
140
|
+
wrapper.unmount();
|
|
141
|
+
const callsBeforeRemount = vi.mocked(store.searchAcrossDatasets).mock.calls.length;
|
|
142
|
+
const remounted = mountSearch();
|
|
143
|
+
await flushPromises();
|
|
144
|
+
expect(remounted.find('input').element.value).toBe('');
|
|
145
|
+
expect(vi.mocked(store.searchAcrossDatasets)).toHaveBeenCalledTimes(callsBeforeRemount);
|
|
146
|
+
remounted.unmount();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('cancels a pending search debounce when cleared', async () => {
|
|
150
|
+
store.searchAcrossDatasets = vi.fn(async () => []);
|
|
151
|
+
const wrapper = mountSearch();
|
|
152
|
+
await wrapper.find('input').setValue('road');
|
|
153
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
154
|
+
|
|
155
|
+
const clearBtn = wrapper.findAll('button').find(b => b.find('svg').exists() && b.element.closest('.relative') !== null);
|
|
156
|
+
expect(clearBtn).toBeDefined();
|
|
157
|
+
await clearBtn!.trigger('click');
|
|
158
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
159
|
+
|
|
160
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
161
|
+
expect(store.searchAcrossDatasets).not.toHaveBeenCalled();
|
|
162
|
+
wrapper.unmount();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it.each(['resolve', 'reject'] as const)('ignores an in-flight search that %s after clearing', async (outcome) => {
|
|
166
|
+
let resolveSearch!: (hits: SearchHit[]) => void;
|
|
167
|
+
let rejectSearch!: (error: Error) => void;
|
|
168
|
+
store.searchAcrossDatasets = vi.fn(() => new Promise<SearchHit[]>((resolve, reject) => {
|
|
169
|
+
resolveSearch = resolve;
|
|
170
|
+
rejectSearch = reject;
|
|
171
|
+
}));
|
|
172
|
+
const ui = useUiStore();
|
|
173
|
+
const wrapper = mountSearch();
|
|
174
|
+
await wrapper.find('input').setValue('road');
|
|
175
|
+
await wrapper.find('form').trigger('submit');
|
|
176
|
+
expect(store.searchAcrossDatasets).toHaveBeenCalledWith('road');
|
|
177
|
+
expect(wrapper.html()).toContain('animate-spin');
|
|
178
|
+
|
|
179
|
+
await wrapper.get('button[type="button"]').trigger('click');
|
|
180
|
+
await flushPromises();
|
|
181
|
+
|
|
182
|
+
if (outcome === 'resolve') {
|
|
183
|
+
resolveSearch([makeHit({ designation: 'road network' })]);
|
|
184
|
+
} else {
|
|
185
|
+
rejectSearch(new Error('Late search failure'));
|
|
186
|
+
}
|
|
187
|
+
await flushPromises();
|
|
188
|
+
|
|
189
|
+
expect(wrapper.find('input').element.value).toBe('');
|
|
190
|
+
expect(ui.searchQuery).toBe('');
|
|
191
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
192
|
+
expect(wrapper.text()).not.toContain('road network');
|
|
193
|
+
expect(wrapper.text()).not.toContain('Late search failure');
|
|
194
|
+
expect(router.currentRoute.value.query.q).toBeUndefined();
|
|
195
|
+
expect(wrapper.html()).not.toContain('animate-spin');
|
|
196
|
+
wrapper.unmount();
|
|
197
|
+
});
|
|
198
|
+
|
|
111
199
|
it('shows loading spinner during search', async () => {
|
|
112
200
|
const wrapper = mountSearch();
|
|
113
201
|
// Mock a slow search
|
|
@@ -137,6 +225,211 @@ describe('SearchBar', () => {
|
|
|
137
225
|
expect(wrapper.text()).toContain('road user');
|
|
138
226
|
});
|
|
139
227
|
|
|
228
|
+
it('lists English before higher-ranked French hits and navigates in rendered order', async () => {
|
|
229
|
+
store.searchAcrossDatasets = vi.fn(async () => [
|
|
230
|
+
makeHit({ conceptId: 'french', designation: 'route', language: 'fra' }),
|
|
231
|
+
makeHit({ conceptId: 'english', designation: 'road', language: 'eng' }),
|
|
232
|
+
]);
|
|
233
|
+
const wrapper = mountSearch();
|
|
234
|
+
await wrapper.find('input').setValue('road');
|
|
235
|
+
await wrapper.find('form').trigger('submit');
|
|
236
|
+
await flushPromises();
|
|
237
|
+
expect(wrapper.findAll('.card-hover').map(hit => hit.text())).toEqual([
|
|
238
|
+
expect.stringContaining('road'), expect.stringContaining('route'),
|
|
239
|
+
]);
|
|
240
|
+
await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' });
|
|
241
|
+
expect(wrapper.find('.search-hit-selected').text()).toContain('road');
|
|
242
|
+
|
|
243
|
+
await wrapper.get('select').setValue('relevance');
|
|
244
|
+
expect(useUiStore().searchSort).toBe('relevance');
|
|
245
|
+
expect(localStorage.getItem('searchSort')).toBe('relevance');
|
|
246
|
+
expect(wrapper.find('.search-hit-selected').exists()).toBe(false);
|
|
247
|
+
expect(wrapper.findAll('.card-hover').map(hit => hit.text())).toEqual([
|
|
248
|
+
expect.stringContaining('route'), expect.stringContaining('road'),
|
|
249
|
+
]);
|
|
250
|
+
expect(store.searchAcrossDatasets).toHaveBeenCalledTimes(1);
|
|
251
|
+
wrapper.unmount();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('includes an English hit beyond raw position 100 on the first page', async () => {
|
|
255
|
+
store.searchAcrossDatasets = vi.fn(async () => [
|
|
256
|
+
...Array.from({ length: 101 }, (_, i) => makeHit({ conceptId: `fra-${i}`, language: 'fra', designation: `route ${i}` })),
|
|
257
|
+
makeHit({ conceptId: 'english', designation: 'road' }),
|
|
258
|
+
]);
|
|
259
|
+
const wrapper = mountSearch();
|
|
260
|
+
await wrapper.find('input').setValue('road');
|
|
261
|
+
await wrapper.find('form').trigger('submit');
|
|
262
|
+
await flushPromises();
|
|
263
|
+
expect(wrapper.findAll('.card-hover')[0].text()).toContain('road');
|
|
264
|
+
expect(wrapper.text()).toContain('1–20 of 102');
|
|
265
|
+
await wrapper.findAll('button').find(button => button.text() === 'Next')!.trigger('click');
|
|
266
|
+
expect(wrapper.text()).toContain('21–40 of 102');
|
|
267
|
+
await wrapper.get('select').setValue('relevance');
|
|
268
|
+
expect(wrapper.text()).toContain('1–20 of 102');
|
|
269
|
+
expect(wrapper.findAll('.card-hover')[0].text()).toContain('route 0');
|
|
270
|
+
wrapper.unmount();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('keeps the English designation when the same concept first matches in French', async () => {
|
|
274
|
+
store.initialized = true;
|
|
275
|
+
const english = makeHit({ designation: 'road' });
|
|
276
|
+
store.datasets.set('test', {
|
|
277
|
+
manifest: makeManifest(), index: [], ensureAllChunksLoaded: async () => {},
|
|
278
|
+
search: () => [makeHit({ language: 'fra', designation: 'route' }), english],
|
|
279
|
+
} as any);
|
|
280
|
+
expect(await store.searchAcrossDatasets('road')).toEqual([english]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it.each([
|
|
284
|
+
{ language: 'fra' },
|
|
285
|
+
{ language: 'eng', languageOrder: ['fra', 'eng'] },
|
|
286
|
+
])('uses configured priorities for listing and concept deduplication: %j', async (defaults) => {
|
|
287
|
+
siteConfig.value = { defaults };
|
|
288
|
+
store.initialized = true;
|
|
289
|
+
store.datasets.set('test', {
|
|
290
|
+
manifest: makeManifest(), index: [], ensureAllChunksLoaded: async () => {},
|
|
291
|
+
search: () => [
|
|
292
|
+
makeHit({ conceptId: 'shared', language: 'eng', designation: 'English duplicate' }),
|
|
293
|
+
makeHit({ conceptId: 'english', language: 'eng', designation: 'road' }),
|
|
294
|
+
makeHit({ conceptId: 'shared', language: 'fra', designation: 'route' }),
|
|
295
|
+
],
|
|
296
|
+
} as any);
|
|
297
|
+
const wrapper = mountSearch();
|
|
298
|
+
await wrapper.find('input').setValue('road');
|
|
299
|
+
await wrapper.find('form').trigger('submit');
|
|
300
|
+
await flushPromises();
|
|
301
|
+
expect(wrapper.findAll('.card-hover').map(hit => hit.text())).toEqual([
|
|
302
|
+
expect.stringContaining('route'), expect.stringContaining('road'),
|
|
303
|
+
]);
|
|
304
|
+
expect(wrapper.text()).not.toContain('English duplicate');
|
|
305
|
+
wrapper.unmount();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('paginates all 23 hits in pages of 20 and disables boundary navigation', async () => {
|
|
309
|
+
const hits = Array.from({ length: 23 }, (_, index) => makeHit({
|
|
310
|
+
conceptId: `term-${index + 1}`,
|
|
311
|
+
designation: `term ${index + 1}`,
|
|
312
|
+
}));
|
|
313
|
+
store.searchAcrossDatasets = vi.fn(async () => hits);
|
|
314
|
+
const wrapper = mountSearch();
|
|
315
|
+
await wrapper.find('input').setValue('term');
|
|
316
|
+
await wrapper.find('form').trigger('submit');
|
|
317
|
+
await flushPromises();
|
|
318
|
+
|
|
319
|
+
const pageButtons = () => wrapper.findAll('button').filter(button => ['Prev', 'Next'].includes(button.text()));
|
|
320
|
+
const hitButtons = () => wrapper.findAll('.search-results button').filter(button => !['Prev', 'Next'].includes(button.text()));
|
|
321
|
+
expect(wrapper.text()).toContain('1–20 of 23');
|
|
322
|
+
expect(hitButtons()).toHaveLength(20);
|
|
323
|
+
expect(hitButtons().map(button => button.text())).toEqual(expect.arrayContaining(
|
|
324
|
+
hits.slice(0, 20).map(hit => expect.stringContaining(hit.designation)),
|
|
325
|
+
));
|
|
326
|
+
expect(pageButtons().map(button => button.text())).toEqual(['Prev', 'Next']);
|
|
327
|
+
expect(pageButtons()[0].element.disabled).toBe(true);
|
|
328
|
+
expect(pageButtons()[1].element.disabled).toBe(false);
|
|
329
|
+
|
|
330
|
+
await pageButtons()[1].trigger('click');
|
|
331
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
332
|
+
await flushPromises();
|
|
333
|
+
expect(wrapper.text()).toContain('21–23 of 23');
|
|
334
|
+
expect(hitButtons()).toHaveLength(3);
|
|
335
|
+
expect(hitButtons().map(button => button.text())).toEqual(expect.arrayContaining([
|
|
336
|
+
expect.stringContaining('term 21'),
|
|
337
|
+
expect.stringContaining('term 22'),
|
|
338
|
+
expect.stringContaining('term 23'),
|
|
339
|
+
]));
|
|
340
|
+
expect(pageButtons()[0].element.disabled).toBe(false);
|
|
341
|
+
expect(pageButtons()[1].element.disabled).toBe(true);
|
|
342
|
+
|
|
343
|
+
await pageButtons()[0].trigger('click');
|
|
344
|
+
expect(wrapper.text()).toContain('1–20 of 23');
|
|
345
|
+
expect(hitButtons()).toHaveLength(20);
|
|
346
|
+
await pageButtons()[1].trigger('click');
|
|
347
|
+
expect(hitButtons().map(button => button.text()).join(' ')).toContain('term 23');
|
|
348
|
+
wrapper.unmount();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('resets pagination when a new search is submitted', async () => {
|
|
352
|
+
const firstHits = Array.from({ length: 23 }, (_, index) => makeHit({ conceptId: `first-${index + 1}`, designation: `first ${index + 1}` }));
|
|
353
|
+
const secondHits = [makeHit({ conceptId: 'second-1', designation: 'second result' })];
|
|
354
|
+
store.searchAcrossDatasets = vi.fn(async (query: string) => query === 'first' ? firstHits : secondHits);
|
|
355
|
+
const wrapper = mountSearch();
|
|
356
|
+
const input = wrapper.find('input');
|
|
357
|
+
await input.setValue('first');
|
|
358
|
+
await wrapper.find('form').trigger('submit');
|
|
359
|
+
await flushPromises();
|
|
360
|
+
await wrapper.findAll('button').find(button => button.text() === 'Next')!.trigger('click');
|
|
361
|
+
expect(wrapper.text()).toContain('21–23 of 23');
|
|
362
|
+
|
|
363
|
+
await input.setValue('second');
|
|
364
|
+
await wrapper.find('form').trigger('submit');
|
|
365
|
+
await flushPromises();
|
|
366
|
+
expect(wrapper.text()).toContain('second result');
|
|
367
|
+
expect(wrapper.text()).not.toContain('21–23 of 23');
|
|
368
|
+
expect(wrapper.text()).toContain('1–1 of 1');
|
|
369
|
+
const paginationButtons = wrapper.findAll('button').filter(button => ['Prev', 'Next'].includes(button.text()));
|
|
370
|
+
expect(paginationButtons).toHaveLength(2);
|
|
371
|
+
expect(paginationButtons.every(button => button.element.disabled)).toBe(true);
|
|
372
|
+
wrapper.unmount();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('resets keyboard selection on page change and selects only current page hits', async () => {
|
|
376
|
+
const hits = Array.from({ length: 23 }, (_, index) => makeHit({
|
|
377
|
+
conceptId: `keyboard-${index + 1}`,
|
|
378
|
+
designation: `keyboard ${index + 1}`,
|
|
379
|
+
}));
|
|
380
|
+
store.searchAcrossDatasets = vi.fn(async () => hits);
|
|
381
|
+
const wrapper = mountSearch();
|
|
382
|
+
const input = wrapper.find('input');
|
|
383
|
+
await input.setValue('keyboard');
|
|
384
|
+
await wrapper.find('form').trigger('submit');
|
|
385
|
+
await flushPromises();
|
|
386
|
+
|
|
387
|
+
for (let index = 0; index < 20; index++) await input.trigger('keydown', { key: 'ArrowDown' });
|
|
388
|
+
expect(wrapper.find('.search-hit-selected').text()).toContain('keyboard 20');
|
|
389
|
+
await wrapper.findAll('button').find(button => button.text() === 'Next')!.trigger('click');
|
|
390
|
+
expect(wrapper.find('.search-hit-selected').exists()).toBe(false);
|
|
391
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
392
|
+
expect(wrapper.find('.search-hit-selected').text()).toContain('keyboard 21');
|
|
393
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
394
|
+
expect(wrapper.find('.search-hit-selected').text()).toContain('keyboard 22');
|
|
395
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
396
|
+
expect(wrapper.find('.search-hit-selected').text()).toContain('keyboard 23');
|
|
397
|
+
await input.trigger('keydown', { key: 'ArrowDown' });
|
|
398
|
+
expect(wrapper.find('.search-hit-selected').text()).toContain('keyboard 23');
|
|
399
|
+
await input.trigger('keydown', { key: 'Enter' });
|
|
400
|
+
await flushPromises();
|
|
401
|
+
expect(router.currentRoute.value.params.conceptId).toBe('keyboard-23');
|
|
402
|
+
wrapper.unmount();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('keeps hits beyond the fifth page reachable', async () => {
|
|
406
|
+
const hits = Array.from({ length: 103 }, (_, index) => makeHit({
|
|
407
|
+
conceptId: `many-${index + 1}`,
|
|
408
|
+
designation: `many ${index + 1}`,
|
|
409
|
+
}));
|
|
410
|
+
store.searchAcrossDatasets = vi.fn(async () => hits);
|
|
411
|
+
const wrapper = mountSearch();
|
|
412
|
+
await wrapper.find('input').setValue('many');
|
|
413
|
+
await wrapper.find('form').trigger('submit');
|
|
414
|
+
await flushPromises();
|
|
415
|
+
|
|
416
|
+
for (let page = 1; page < 6; page++) {
|
|
417
|
+
const next = wrapper.findAll('button').find(button => button.text() === 'Next');
|
|
418
|
+
expect(next).toBeDefined();
|
|
419
|
+
expect(next!.element.disabled).toBe(false);
|
|
420
|
+
await next!.trigger('click');
|
|
421
|
+
}
|
|
422
|
+
expect(wrapper.text()).toContain('101–103 of 103');
|
|
423
|
+
const pageHits = wrapper.findAll('.search-results button').filter(button => !['Prev', 'Next'].includes(button.text()));
|
|
424
|
+
expect(pageHits.map(button => button.text())).toEqual(expect.arrayContaining([
|
|
425
|
+
expect.stringContaining('many 101'),
|
|
426
|
+
expect.stringContaining('many 102'),
|
|
427
|
+
expect.stringContaining('many 103'),
|
|
428
|
+
]));
|
|
429
|
+
expect(wrapper.findAll('button').find(button => button.text() === 'Next')!.element.disabled).toBe(true);
|
|
430
|
+
wrapper.unmount();
|
|
431
|
+
});
|
|
432
|
+
|
|
140
433
|
it('shows empty state when no results', async () => {
|
|
141
434
|
store.searchAcrossDatasets = vi.fn(async () => []);
|
|
142
435
|
const wrapper = mountSearch();
|
|
@@ -189,6 +482,17 @@ describe('SearchBar', () => {
|
|
|
189
482
|
expect(wrapper.text()).toContain('ID match');
|
|
190
483
|
});
|
|
191
484
|
|
|
485
|
+
it('shows the coloured entry status badge for a superseded hit', async () => {
|
|
486
|
+
store.searchAcrossDatasets = vi.fn(async () => [makeHit({ status: 'superseded' })]);
|
|
487
|
+
const wrapper = mountSearch();
|
|
488
|
+
await wrapper.find('input').setValue('road');
|
|
489
|
+
await wrapper.find('form').trigger('submit');
|
|
490
|
+
await flushPromises();
|
|
491
|
+
const badge = wrapper.findAll('.badge').find(b => b.text() === entryStatusLabel('superseded'));
|
|
492
|
+
expect(badge).toBeDefined();
|
|
493
|
+
expect(badge!.classes()).toEqual(expect.arrayContaining(entryStatusColor('superseded').split(' ')));
|
|
494
|
+
});
|
|
495
|
+
|
|
192
496
|
it('updates URL query parameter on search', async () => {
|
|
193
497
|
store.searchAcrossDatasets = vi.fn(async () => []);
|
|
194
498
|
const wrapper = mountSearch();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import SearchResults from '../components/SearchResults.vue';
|
|
4
|
+
import { entryStatusColor, entryStatusDefinition, entryStatusLabel } from '../utils/concept-helpers';
|
|
5
|
+
import { setupPinia, makeSearchHit } from './test-helpers';
|
|
6
|
+
|
|
7
|
+
describe('SearchResults', () => {
|
|
8
|
+
function mountResults(hits: ReturnType<typeof makeSearchHit>[]) {
|
|
9
|
+
const pinia = setupPinia();
|
|
10
|
+
return mount(SearchResults, {
|
|
11
|
+
props: {
|
|
12
|
+
loading: false,
|
|
13
|
+
searchError: null,
|
|
14
|
+
searched: true,
|
|
15
|
+
searchQuery: 'term',
|
|
16
|
+
results: hits,
|
|
17
|
+
groupedResults: [{ registerId: 'test', title: 'Test Dataset', hits }],
|
|
18
|
+
selectedIdx: -1,
|
|
19
|
+
},
|
|
20
|
+
global: { plugins: [pinia] },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('shows entry status labels, taxonomy colors, and definitions while preserving search-hit behavior', async () => {
|
|
25
|
+
const valid = makeSearchHit({ conceptId: '1', designation: 'Current term', language: 'eng', matchField: 'id', status: 'valid' });
|
|
26
|
+
const superseded = makeSearchHit({ conceptId: '2', designation: 'Old term', language: 'fra', status: 'superseded' });
|
|
27
|
+
const wrapper = mountResults([valid, superseded]);
|
|
28
|
+
const buttons = wrapper.findAll('button');
|
|
29
|
+
const validBadge = buttons[0].findAll('.badge').find(badge => badge.text() === entryStatusLabel('valid'));
|
|
30
|
+
const supersededBadge = buttons[1].findAll('.badge').find(badge => badge.text() === entryStatusLabel('superseded'));
|
|
31
|
+
|
|
32
|
+
expect(validBadge).toBeDefined();
|
|
33
|
+
expect(validBadge!.classes()).toContain('badge-green');
|
|
34
|
+
expect(validBadge!.classes()).toEqual(expect.arrayContaining(entryStatusColor('valid').split(' ')));
|
|
35
|
+
expect(validBadge!.attributes('title')).toBe(entryStatusDefinition('valid'));
|
|
36
|
+
expect(supersededBadge).toBeDefined();
|
|
37
|
+
expect(supersededBadge!.classes()).toContain('bg-red-50');
|
|
38
|
+
expect(supersededBadge!.classes()).toContain('text-red-700');
|
|
39
|
+
expect(supersededBadge!.classes()).toEqual(expect.arrayContaining(entryStatusColor('superseded').split(' ')));
|
|
40
|
+
expect(supersededBadge!.attributes('title')).toBe(entryStatusDefinition('superseded'));
|
|
41
|
+
|
|
42
|
+
expect(buttons[0].text()).toContain('Current term');
|
|
43
|
+
expect(buttons[0].text()).toContain('ID');
|
|
44
|
+
expect(buttons[0].text()).toContain('English');
|
|
45
|
+
expect(buttons[1].text()).toContain('French');
|
|
46
|
+
await buttons[0].trigger('click');
|
|
47
|
+
expect(wrapper.emitted('goHit')?.[0]).toEqual([valid]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not render an entry status badge when a hit has no status', () => {
|
|
51
|
+
const wrapper = mountResults([makeSearchHit({ designation: 'Unclassified term' })]);
|
|
52
|
+
expect(wrapper.findAll('.badge')).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { deduplicateSearchHits } from '../utils/search';
|
|
2
|
+
import { deduplicateSearchHits, orderSearchHits } from '../utils/search';
|
|
3
3
|
import type { SearchHit } from '../adapters/types';
|
|
4
4
|
|
|
5
5
|
function makeHit(overrides: Partial<SearchHit> = {}): SearchHit {
|
|
@@ -56,4 +56,67 @@ describe('deduplicateSearchHits', () => {
|
|
|
56
56
|
const result = deduplicateSearchHits([hit1, hit2]);
|
|
57
57
|
expect(result.length).toBe(2);
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
it('keeps the designation in the earlier priority language', () => {
|
|
61
|
+
const fraHit = makeHit({ registerId: 'viml', conceptId: '1', language: 'fra' });
|
|
62
|
+
const engHit = makeHit({ registerId: 'viml', conceptId: '1', language: 'eng' });
|
|
63
|
+
|
|
64
|
+
const result = deduplicateSearchHits([fraHit, engHit], ['eng']);
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual([engHit]);
|
|
67
|
+
expect(deduplicateSearchHits([engHit, fraHit], ['eng'])).toEqual([engHit]);
|
|
68
|
+
expect(deduplicateSearchHits([fraHit, engHit])).toEqual([fraHit]);
|
|
69
|
+
expect(deduplicateSearchHits([fraHit, engHit], ['eng', 'fra'])).toEqual([engHit]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps the first designation when language tiers are equal', () => {
|
|
73
|
+
const spaHit = makeHit({ registerId: 'viml', conceptId: '1', language: 'spa' });
|
|
74
|
+
const deuHit = makeHit({ registerId: 'viml', conceptId: '1', language: 'deu' });
|
|
75
|
+
|
|
76
|
+
const result = deduplicateSearchHits([spaHit, deuHit], ['eng', 'fra']);
|
|
77
|
+
|
|
78
|
+
expect(result).toEqual([spaHit]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps designation matches ahead of ID matches with language priorities', () => {
|
|
82
|
+
const idHit = makeHit({ registerId: 'viml', conceptId: '1', matchField: 'id', language: 'eng' });
|
|
83
|
+
const desHit = makeHit({ registerId: 'viml', conceptId: '1', matchField: 'designation', language: '' });
|
|
84
|
+
|
|
85
|
+
expect(deduplicateSearchHits([idHit, desHit], ['eng'])).toEqual([desHit]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('orderSearchHits', () => {
|
|
90
|
+
it('orders ID hits first, then configured languages, then one stable unlisted tier', () => {
|
|
91
|
+
const hits = [
|
|
92
|
+
makeHit({ conceptId: 'unlisted-a', language: 'ita' }),
|
|
93
|
+
makeHit({ conceptId: 'fra', language: 'fra' }),
|
|
94
|
+
makeHit({ conceptId: 'id', language: '', matchField: 'id' }),
|
|
95
|
+
makeHit({ conceptId: 'spa', language: 'spa' }),
|
|
96
|
+
makeHit({ conceptId: 'eng', language: 'eng' }),
|
|
97
|
+
makeHit({ conceptId: 'unlisted-b', language: 'deu' }),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const result = orderSearchHits(hits, ['eng', 'fra', 'spa']);
|
|
101
|
+
|
|
102
|
+
expect(result.map(({ conceptId }) => conceptId)).toEqual([
|
|
103
|
+
'id', 'eng', 'fra', 'spa', 'unlisted-a', 'unlisted-b',
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('preserves incoming score and alphabetical order within each language tier without mutating input', () => {
|
|
108
|
+
const fra = { ...makeHit({ conceptId: 'fra', language: 'fra' }), _score: 9 };
|
|
109
|
+
const engHigh = { ...makeHit({ conceptId: 'eng-high', designation: 'zebra' }), _score: 8 };
|
|
110
|
+
const spa = { ...makeHit({ conceptId: 'spa', language: 'spa' }), _score: 7 };
|
|
111
|
+
const engAlpha = { ...makeHit({ conceptId: 'eng-alpha', designation: 'alpha' }), _score: 2 };
|
|
112
|
+
const engBeta = { ...makeHit({ conceptId: 'eng-beta', designation: 'beta' }), _score: 2 };
|
|
113
|
+
const hits = [fra, engHigh, spa, engAlpha, engBeta];
|
|
114
|
+
const original = [...hits];
|
|
115
|
+
|
|
116
|
+
const result = orderSearchHits(hits, ['eng']);
|
|
117
|
+
|
|
118
|
+
expect(result).toEqual([engHigh, engAlpha, engBeta, fra, spa]);
|
|
119
|
+
expect(hits).toEqual(original);
|
|
120
|
+
expect(result).not.toBe(hits);
|
|
121
|
+
});
|
|
59
122
|
});
|