@glossarist/concept-browser 0.7.138 → 0.7.140
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/__tests__/standards-index.test.ts +121 -0
- package/scripts/build-edges.js +20 -3
- package/scripts/build-edges.ts +1 -6
- package/scripts/generate-data.js +51 -3
- package/scripts/generate-data.ts +37 -3
- package/src/__fixtures__/isotc211-domain-slugs.json +201 -0
- package/src/__tests__/app-footer.test.ts +36 -0
- package/src/__tests__/app-header.test.ts +32 -5
- package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
- package/src/__tests__/concept-view.test.ts +103 -0
- package/src/__tests__/dataset-adapter.test.ts +151 -1
- package/src/__tests__/dataset-view.test.ts +41 -1
- package/src/__tests__/model-bridge-citation.test.ts +20 -0
- package/src/__tests__/same-standard-pane.test.ts +174 -0
- package/src/__tests__/scripts/generate-data.test.ts +74 -0
- package/src/__tests__/search-across-datasets.test.ts +102 -0
- package/src/__tests__/search-bar.test.ts +304 -0
- package/src/__tests__/search-results.test.ts +54 -0
- package/src/__tests__/search-utils.test.ts +64 -1
- package/src/__tests__/search-view.test.ts +194 -2
- package/src/__tests__/slugify.test.ts +74 -1
- package/src/__tests__/source-standard.test.ts +70 -0
- package/src/__tests__/ui-store.test.ts +26 -0
- package/src/__tests__/vocabulary-layered.test.ts +7 -7
- package/src/adapters/DatasetAdapter.ts +16 -8
- package/src/adapters/jsonld-types.ts +1 -0
- package/src/adapters/model-bridge.ts +1 -0
- package/src/adapters/standards-index.ts +7 -0
- package/src/adapters/types.ts +1 -0
- package/src/components/AppFooter.vue +7 -0
- package/src/components/AppHeader.vue +6 -5
- package/src/components/SameStandardPane.vue +70 -0
- package/src/components/SearchBar.vue +73 -18
- package/src/components/SearchResults.vue +46 -5
- package/src/components/site/SiteFooter.astro +6 -0
- package/src/composables/use-same-standard-terms.ts +106 -0
- package/src/config/types.ts +11 -0
- package/src/config/use-site-config.ts +1 -0
- package/src/i18n/locales/eng.yml +10 -1
- package/src/i18n/locales/fra.yml +10 -1
- package/src/stores/ui.ts +16 -1
- package/src/stores/vocabulary.ts +27 -31
- package/src/utils/search.ts +14 -1
- package/src/utils/slugify.ts +25 -1
- package/src/utils/source-standard.ts +34 -0
- package/src/views/ConceptView.vue +43 -12
- package/src/views/DatasetView.vue +25 -5
- package/src/views/SearchView.vue +9 -12
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
3
|
+
import { createRouter, createMemoryHistory, RouterView } from 'vue-router';
|
|
3
4
|
import SearchView from '../views/SearchView.vue';
|
|
5
|
+
import { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
4
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
|
-
import {
|
|
7
|
+
import { useUiStore } from '../stores/ui';
|
|
8
|
+
import { createTestRouter, setupPinia, makeManifest, makeAdapterStub, makeSearchHit } from './test-helpers';
|
|
6
9
|
|
|
7
10
|
describe('SearchView', () => {
|
|
8
11
|
let pinia: ReturnType<typeof setupPinia>;
|
|
@@ -12,22 +15,119 @@ describe('SearchView', () => {
|
|
|
12
15
|
pinia = setupPinia();
|
|
13
16
|
router = await createTestRouter('search', '/search');
|
|
14
17
|
const store = useVocabularyStore();
|
|
18
|
+
store.initialized = true;
|
|
15
19
|
store.manifests.set('test', makeManifest());
|
|
16
20
|
store.datasets.set('test', makeAdapterStub());
|
|
17
21
|
});
|
|
18
22
|
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
});
|
|
26
|
+
|
|
19
27
|
function mountView() {
|
|
20
28
|
return mount(SearchView, {
|
|
21
29
|
global: { plugins: [pinia, router], stubs: { SearchBar: true } },
|
|
22
30
|
});
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
async function mountRoutedSearch(path = '/search') {
|
|
34
|
+
router = createRouter({
|
|
35
|
+
history: createMemoryHistory(),
|
|
36
|
+
routes: [
|
|
37
|
+
{ path: '/', name: 'home', component: { template: '<div>Home page</div>' } },
|
|
38
|
+
{ path: '/search', name: 'search', component: SearchView },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
await router.push(path);
|
|
42
|
+
await router.isReady();
|
|
43
|
+
const wrapper = mount({ components: { RouterView }, template: '<RouterView />' }, {
|
|
44
|
+
global: { plugins: [pinia, router] },
|
|
45
|
+
});
|
|
46
|
+
await flushPromises();
|
|
47
|
+
return wrapper;
|
|
48
|
+
}
|
|
49
|
+
|
|
25
50
|
it('renders breadcrumb navigation', () => {
|
|
26
51
|
const wrapper = mountView();
|
|
27
52
|
expect(wrapper.text()).toContain('Home');
|
|
28
53
|
expect(wrapper.text()).toContain('Search');
|
|
29
54
|
});
|
|
30
55
|
|
|
56
|
+
it.each(['transient', 'persistent'] as const)('warns about a %s chunk failure and retries from the routed search', async (failure) => {
|
|
57
|
+
const manifest = makeManifest({ conceptCount: 2001 });
|
|
58
|
+
const adapter = new DatasetAdapter('test', '/data/test');
|
|
59
|
+
adapter.manifest = manifest;
|
|
60
|
+
const store = useVocabularyStore();
|
|
61
|
+
store.manifests.set('test', manifest);
|
|
62
|
+
store.datasets.set('test', adapter);
|
|
63
|
+
|
|
64
|
+
const chunks = [
|
|
65
|
+
Array.from({ length: 1000 }, (_, i) => ({
|
|
66
|
+
id: i === 0 ? '276' : `first-${i}`,
|
|
67
|
+
designations: { eng: i === 0 ? 'location' : `unrelated first ${i}` },
|
|
68
|
+
status: i === 0 ? 'superseded' : 'valid',
|
|
69
|
+
})),
|
|
70
|
+
Array.from({ length: 1000 }, (_, i) => ({
|
|
71
|
+
id: i === 0 ? '2345' : `second-${i}`,
|
|
72
|
+
designations: { eng: i === 0 ? 'location' : `unrelated second ${i}` },
|
|
73
|
+
status: 'valid',
|
|
74
|
+
})),
|
|
75
|
+
[{ id: '9999', designations: { eng: 'unrelated last entry' }, status: 'valid' }],
|
|
76
|
+
];
|
|
77
|
+
let failedChunkRequests = 0;
|
|
78
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
79
|
+
const url = String(input);
|
|
80
|
+
if (url === '/data/test/index-meta.json') {
|
|
81
|
+
return {
|
|
82
|
+
ok: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
json: async () => ({
|
|
85
|
+
registerId: 'test', schemaVersion: '1.0.0', conceptCount: 2001, chunkSize: 1000,
|
|
86
|
+
chunks: chunks.map((concepts, i) => ({ file: `index-${i}.json`, count: concepts.length })),
|
|
87
|
+
}),
|
|
88
|
+
} as Response;
|
|
89
|
+
}
|
|
90
|
+
const chunkIndex = chunks.findIndex((_, i) => url === `/data/test/chunks/index-${i}.json`);
|
|
91
|
+
if (chunkIndex < 0) throw new Error(`Unexpected fetch: ${url}`);
|
|
92
|
+
if (chunkIndex === 1 && (++failedChunkRequests === 1 || failure === 'persistent')) {
|
|
93
|
+
return { ok: false, status: 404 } as Response;
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, status: 200, json: async () => ({ concepts: chunks[chunkIndex] }) } as Response;
|
|
96
|
+
});
|
|
97
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
98
|
+
|
|
99
|
+
const wrapper = await mountRoutedSearch('/search?q=location');
|
|
100
|
+
try {
|
|
101
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('location');
|
|
102
|
+
expect(wrapper.get('[role="alert"]').text()).toContain('Some results could not be loaded');
|
|
103
|
+
expect(wrapper.get('.search-results').text()).toContain('276');
|
|
104
|
+
expect(wrapper.get('.search-results').text()).toContain('superseded');
|
|
105
|
+
expect(wrapper.get('.search-results').text()).not.toContain('2345');
|
|
106
|
+
expect(wrapper.get('.search-results').text()).not.toContain('9999');
|
|
107
|
+
|
|
108
|
+
const retry = wrapper.findAll('button').find(button => button.text() === 'Retry');
|
|
109
|
+
expect(retry).toBeDefined();
|
|
110
|
+
await retry!.trigger('click');
|
|
111
|
+
await flushPromises();
|
|
112
|
+
|
|
113
|
+
expect(failedChunkRequests).toBe(2);
|
|
114
|
+
expect(router.currentRoute.value.fullPath).toBe('/search?q=location');
|
|
115
|
+
expect(wrapper.get('.search-results').text()).toContain('276');
|
|
116
|
+
expect(wrapper.get('.search-results').text()).not.toContain('9999');
|
|
117
|
+
if (failure === 'transient') {
|
|
118
|
+
expect(wrapper.get('.search-results').text()).toContain('2345');
|
|
119
|
+
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
|
|
120
|
+
} else {
|
|
121
|
+
expect(wrapper.get('.search-results').text()).not.toContain('2345');
|
|
122
|
+
expect(wrapper.get('[role="alert"]').text()).toContain('Some results could not be loaded');
|
|
123
|
+
}
|
|
124
|
+
expect(fetchMock.mock.calls.filter(([url]) => url === '/data/test/chunks/index-0.json')).toHaveLength(1);
|
|
125
|
+
expect(fetchMock.mock.calls.filter(([url]) => url === '/data/test/chunks/index-2.json')).toHaveLength(1);
|
|
126
|
+
} finally {
|
|
127
|
+
wrapper.unmount();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
31
131
|
it('renders SearchBar component', () => {
|
|
32
132
|
const wrapper = mountView();
|
|
33
133
|
expect(wrapper.findComponent({ name: 'SearchBar' }).exists()).toBe(true);
|
|
@@ -38,4 +138,96 @@ describe('SearchView', () => {
|
|
|
38
138
|
const homeLink = wrapper.findAll('a').find(a => a.text() === 'Home');
|
|
39
139
|
expect(homeLink).toBeDefined();
|
|
40
140
|
});
|
|
141
|
+
|
|
142
|
+
it('searches an initial query parameter and restores it when returning to search', async () => {
|
|
143
|
+
const store = useVocabularyStore();
|
|
144
|
+
const search = vi.fn(() => [makeSearchHit({ designation: 'Foo result' })]);
|
|
145
|
+
store.datasets.set('test', makeAdapterStub({ search }));
|
|
146
|
+
const wrapper = await mountRoutedSearch('/search?q=foo');
|
|
147
|
+
const ui = useUiStore();
|
|
148
|
+
await flushPromises();
|
|
149
|
+
|
|
150
|
+
expect(search).toHaveBeenCalledWith('foo');
|
|
151
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
152
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('foo');
|
|
153
|
+
expect(wrapper.text()).toContain('Foo result');
|
|
154
|
+
|
|
155
|
+
await router.push('/');
|
|
156
|
+
await flushPromises();
|
|
157
|
+
expect(wrapper.text()).toContain('Home page');
|
|
158
|
+
expect(ui.searchQuery).toBe('');
|
|
159
|
+
|
|
160
|
+
await router.push('/search');
|
|
161
|
+
await flushPromises();
|
|
162
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
163
|
+
expect(ui.searchQuery).toBe('');
|
|
164
|
+
expect(wrapper.text()).not.toContain('Foo result');
|
|
165
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
166
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
167
|
+
|
|
168
|
+
await router.push('/');
|
|
169
|
+
await flushPromises();
|
|
170
|
+
await router.push('/search?q=bar');
|
|
171
|
+
await flushPromises();
|
|
172
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('bar');
|
|
173
|
+
expect(search).toHaveBeenLastCalledWith('bar');
|
|
174
|
+
wrapper.unmount();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('ignores a leftover store query when mounting without a query parameter', async () => {
|
|
178
|
+
const ui = useUiStore();
|
|
179
|
+
ui.searchQuery = 'stale';
|
|
180
|
+
const search = vi.spyOn(useVocabularyStore(), 'searchAcrossDatasets');
|
|
181
|
+
const wrapper = await mountRoutedSearch('/search');
|
|
182
|
+
|
|
183
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
184
|
+
expect(ui.searchQuery).toBe('');
|
|
185
|
+
expect(search).not.toHaveBeenCalled();
|
|
186
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
187
|
+
wrapper.unmount();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('clears the search when the query parameter is removed while mounted', async () => {
|
|
191
|
+
const store = useVocabularyStore();
|
|
192
|
+
const search = vi.fn(() => [makeSearchHit({ designation: 'Foo result' })]);
|
|
193
|
+
store.datasets.set('test', makeAdapterStub({ search }));
|
|
194
|
+
const wrapper = await mountRoutedSearch('/search?q=foo');
|
|
195
|
+
const ui = useUiStore();
|
|
196
|
+
await flushPromises();
|
|
197
|
+
expect(wrapper.text()).toContain('Foo result');
|
|
198
|
+
|
|
199
|
+
await router.replace('/search?q=bar');
|
|
200
|
+
await flushPromises();
|
|
201
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('bar');
|
|
202
|
+
expect(search).toHaveBeenLastCalledWith('bar');
|
|
203
|
+
|
|
204
|
+
await router.replace('/search');
|
|
205
|
+
await flushPromises();
|
|
206
|
+
|
|
207
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('');
|
|
208
|
+
expect(ui.searchQuery).toBe('');
|
|
209
|
+
expect(wrapper.text()).not.toContain('Foo result');
|
|
210
|
+
expect(wrapper.find('.search-results').exists()).toBe(false);
|
|
211
|
+
wrapper.unmount();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not let a pending search restore the URL after leaving search', async () => {
|
|
215
|
+
const store = useVocabularyStore();
|
|
216
|
+
let resolveSearch!: (hits: ReturnType<typeof makeSearchHit>[]) => void;
|
|
217
|
+
const search = vi.spyOn(store, 'searchAcrossDatasets').mockImplementation(() => new Promise(resolve => {
|
|
218
|
+
resolveSearch = resolve;
|
|
219
|
+
}));
|
|
220
|
+
const wrapper = await mountRoutedSearch('/search?q=foo');
|
|
221
|
+
await flushPromises();
|
|
222
|
+
expect(search).toHaveBeenCalledWith('foo');
|
|
223
|
+
|
|
224
|
+
await router.push('/');
|
|
225
|
+
resolveSearch([makeSearchHit({ designation: 'Late result' })]);
|
|
226
|
+
await flushPromises();
|
|
227
|
+
|
|
228
|
+
expect(router.currentRoute.value.fullPath).toBe('/');
|
|
229
|
+
expect(wrapper.text()).toContain('Home page');
|
|
230
|
+
expect(wrapper.text()).not.toContain('Late result');
|
|
231
|
+
wrapper.unmount();
|
|
232
|
+
});
|
|
41
233
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { slugify } from '../utils/slugify';
|
|
3
|
+
import publishedSlugs from '../__fixtures__/isotc211-domain-slugs.json';
|
|
3
4
|
|
|
4
5
|
describe('slugify', () => {
|
|
5
6
|
it('lowercases text', () => {
|
|
@@ -11,7 +12,7 @@ describe('slugify', () => {
|
|
|
11
12
|
});
|
|
12
13
|
|
|
13
14
|
it('strips non-word characters', () => {
|
|
14
|
-
expect(slugify('géométrie (2D)')).toBe('
|
|
15
|
+
expect(slugify('géométrie (2D)')).toBe('géométrie-2d');
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
it('collapses multiple spaces into single hyphen', () => {
|
|
@@ -25,4 +26,76 @@ describe('slugify', () => {
|
|
|
25
26
|
it('preserves hyphens in input', () => {
|
|
26
27
|
expect(slugify('iso-19107')).toBe('iso-19107');
|
|
27
28
|
});
|
|
29
|
+
|
|
30
|
+
describe('unicode labels', () => {
|
|
31
|
+
it('keeps accented letters', () => {
|
|
32
|
+
expect(slugify('dirección postal')).toBe('dirección-postal');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps non-Latin scripts', () => {
|
|
36
|
+
expect(slugify('геопозиционирование')).toBe('геопозиционирование');
|
|
37
|
+
expect(slugify('地理定位')).toBe('地理定位');
|
|
38
|
+
expect(slugify('지리 위치')).toBe('지리-위치');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps Arabic combining marks', () => {
|
|
42
|
+
expect(slugify('نظام الإحداثيات')).toBe('نظام-الإحداثيات');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is NFC-stable: composed and decomposed input give one slug', () => {
|
|
46
|
+
expect(slugify('é')).toBe(slugify('é'));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('gives distinct labels distinct slugs', () => {
|
|
50
|
+
const labels = ['UML', 'dirección postal', 'direccion postal', 'геопозиционирование',
|
|
51
|
+
'地理定位', '지리 위치', 'تحديد المواقع'];
|
|
52
|
+
expect(new Set(labels.map(slugify)).size).toBe(labels.length);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('is idempotent', () => {
|
|
56
|
+
for (const l of ['dirección postal', 'геопозиционирование', '()', 'a b']) {
|
|
57
|
+
expect(slugify(slugify(l))).toBe(slugify(l));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('labels without letters or digits', () => {
|
|
63
|
+
it('never returns an empty or separator-only slug', () => {
|
|
64
|
+
for (const l of ['()', '---', ' ', '!!', '/', '- -']) {
|
|
65
|
+
expect(slugify(l)).toMatch(/^x-[0-9a-f]{8}$/);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('distinguishes different such labels', () => {
|
|
70
|
+
expect(slugify('()')).not.toBe(slugify('[]'));
|
|
71
|
+
expect(slugify('()')).toBe(slugify('()'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('still maps empty input to empty (callers guard on truthy)', () => {
|
|
75
|
+
expect(slugify('')).toBe('');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('existing ASCII URLs do not move', () => {
|
|
80
|
+
// Verbatim copy of the pre-unicode implementation.
|
|
81
|
+
const legacy = (t: string) => t.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
|
|
82
|
+
|
|
83
|
+
it('matches every domain slug published in the isotc211 dataset', () => {
|
|
84
|
+
const entries = Object.entries(publishedSlugs as Record<string, string>);
|
|
85
|
+
expect(entries.length).toBeGreaterThan(150);
|
|
86
|
+
for (const [label, slug] of entries) expect(slugify(label)).toBe(slug);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('is byte-identical to the legacy output for ASCII input with a letter or digit', () => {
|
|
90
|
+
let seed = 12345;
|
|
91
|
+
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
|
|
92
|
+
for (let i = 0; i < 20000; i++) {
|
|
93
|
+
const len = 1 + Math.floor(rnd() * 12);
|
|
94
|
+
let t = '';
|
|
95
|
+
for (let j = 0; j < len; j++) t += String.fromCharCode(9 + Math.floor(rnd() * 118));
|
|
96
|
+
if (!/[A-Za-z0-9]/.test(t)) continue;
|
|
97
|
+
expect(slugify(t)).toBe(legacy(t));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
28
101
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Citation, Concept } from 'glossarist';
|
|
3
|
+
import { collectStandardKeys, standardKey, standardLabel } from '../utils/source-standard';
|
|
4
|
+
|
|
5
|
+
describe('standard references', () => {
|
|
6
|
+
it.each([
|
|
7
|
+
[{ source: 'ISO', id: '19165-2:2020' }, 'iso 19165-2:2020', 'ISO 19165-2:2020'],
|
|
8
|
+
[{ id: '19165-2:2020' }, '19165-2:2020', '19165-2:2020'],
|
|
9
|
+
[{ source: 'ISO 19165-2:2020' }, 'iso 19165-2:2020', 'ISO 19165-2:2020'],
|
|
10
|
+
[{ source: ' ISO\t IEC ', id: ' 17000:2020\n' }, 'iso iec 17000:2020', 'ISO IEC 17000:2020'],
|
|
11
|
+
[{ source: ' ', id: '19165-2:2020' }, '19165-2:2020', '19165-2:2020'],
|
|
12
|
+
])('combines usable source and id: %j', (ref, key, label) => {
|
|
13
|
+
expect(standardKey(ref)).toBe(key);
|
|
14
|
+
expect(standardLabel(ref)).toBe(label);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each([undefined, null, {}, { source: ' \t', id: '\n' }])('returns null for unusable ref %j', (ref) => {
|
|
18
|
+
expect(standardKey(ref)).toBeNull();
|
|
19
|
+
expect(standardLabel(ref)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('ignores clauses and localities without changing the input', () => {
|
|
23
|
+
const ref = Object.freeze({ source: 'ISO', id: '19165-2:2020', clause: '3.1', locality: '3.1' });
|
|
24
|
+
const otherClause = { ...ref, clause: '4.2', locality: '4.2' };
|
|
25
|
+
expect(standardKey(ref)).toBe('iso 19165-2:2020');
|
|
26
|
+
expect(standardKey(otherClause)).toBe(standardKey(ref));
|
|
27
|
+
expect(standardLabel(ref)).toBe('ISO 19165-2:2020');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('distinguishes standards and editions but ignores case', () => {
|
|
31
|
+
const ref = { source: 'ISO', id: '19165-2:2020' };
|
|
32
|
+
expect(standardKey({ ...ref, source: 'iso' })).toBe(standardKey(ref));
|
|
33
|
+
expect(standardKey({ ...ref, id: '19165-2:2024' })).not.toBe(standardKey(ref));
|
|
34
|
+
expect(standardKey({ ...ref, id: '19115:2020' })).not.toBe(standardKey(ref));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('collectStandardKeys', () => {
|
|
39
|
+
it('collects unique authoritative references across the concept and all languages', () => {
|
|
40
|
+
const source = (id: string, type = 'authoritative') => ({ type, origin: { ref: { source: 'ISO', id } } });
|
|
41
|
+
const concept = Concept.fromJSON({
|
|
42
|
+
id: '1',
|
|
43
|
+
sources: [source('19165-2:2020'), source('704:2022', 'lineage'), { type: 'authoritative' }],
|
|
44
|
+
localizations: {
|
|
45
|
+
eng: { sources: [source('19165-2:2020'), source('19165-2:2024')] },
|
|
46
|
+
fra: { sources: [source('1087:2019'), source('999:2020', 'lineage'), source('998:2020', '')] },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020', 'iso 19165-2:2024', 'iso 1087:2019']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns no keys for a concept without sources', () => {
|
|
53
|
+
expect(collectStandardKeys(Concept.fromJSON({ id: '1' }))).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('deduplicates citations of different clauses of the same standard', () => {
|
|
57
|
+
const concept = Concept.fromJSON({
|
|
58
|
+
id: '1',
|
|
59
|
+
sources: ['3.1', '4.2'].map(clause => ({
|
|
60
|
+
type: 'authoritative',
|
|
61
|
+
origin: Citation.fromJSON({
|
|
62
|
+
ref: { source: 'ISO', id: '19165-2:2020' },
|
|
63
|
+
locality: { type: 'clause', reference_from: clause },
|
|
64
|
+
}),
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
expect(concept.sources[0].origin?.locality).not.toEqual(concept.sources[1].origin?.locality);
|
|
68
|
+
expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -97,4 +97,30 @@ describe('useUiStore', () => {
|
|
|
97
97
|
ui.setTheme('light');
|
|
98
98
|
expect(ui.isDark).toBe(false);
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
it('defaults searchSort to language', () => {
|
|
102
|
+
const ui = useUiStore();
|
|
103
|
+
expect(ui.searchSort).toBe('language');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('setSearchSort updates value and localStorage', () => {
|
|
107
|
+
const ui = useUiStore();
|
|
108
|
+
ui.setSearchSort('relevance');
|
|
109
|
+
expect(ui.searchSort).toBe('relevance');
|
|
110
|
+
expect(localStorage.getItem('searchSort')).toBe('relevance');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('reads stored searchSort from localStorage', () => {
|
|
114
|
+
localStorage.setItem('searchSort', 'relevance');
|
|
115
|
+
setActivePinia(createPinia());
|
|
116
|
+
const ui = useUiStore();
|
|
117
|
+
expect(ui.searchSort).toBe('relevance');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('falls back to language for a junk stored searchSort', () => {
|
|
121
|
+
localStorage.setItem('searchSort', 'bogus');
|
|
122
|
+
setActivePinia(createPinia());
|
|
123
|
+
const ui = useUiStore();
|
|
124
|
+
expect(ui.searchSort).toBe('language');
|
|
125
|
+
});
|
|
100
126
|
});
|
|
@@ -178,7 +178,7 @@ describe('vocabulary store — layered loading', () => {
|
|
|
178
178
|
});
|
|
179
179
|
|
|
180
180
|
describe('searchAcrossDatasets — two-pass', () => {
|
|
181
|
-
it('
|
|
181
|
+
it('loads every chunk even when the index already has 20 or more hits', async () => {
|
|
182
182
|
const store = useVocabularyStore();
|
|
183
183
|
store.initialized = true;
|
|
184
184
|
|
|
@@ -207,11 +207,11 @@ describe('vocabulary store — layered loading', () => {
|
|
|
207
207
|
const results = await store.searchAcrossDatasets('concept');
|
|
208
208
|
|
|
209
209
|
expect(results.length).toBe(25);
|
|
210
|
-
//
|
|
211
|
-
expect(chunksLoaded).toBe(
|
|
210
|
+
// Load every chunk even when the index already has at least 20 hits.
|
|
211
|
+
expect(chunksLoaded).toBe(true);
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
-
it('loads
|
|
214
|
+
it('loads every dataset regardless of whether its index has hits', async () => {
|
|
215
215
|
const store = useVocabularyStore();
|
|
216
216
|
store.initialized = true;
|
|
217
217
|
|
|
@@ -258,9 +258,9 @@ describe('vocabulary store — layered loading', () => {
|
|
|
258
258
|
const results = await store.searchAcrossDatasets('item');
|
|
259
259
|
|
|
260
260
|
expect(results.length).toBe(6); // 5 from ds1 index + 1 from ds2 chunks
|
|
261
|
-
// Adapter 1
|
|
262
|
-
expect(adapter1ChunksLoaded).toBe(
|
|
263
|
-
// Adapter 2
|
|
261
|
+
// Adapter 1 loads every chunk even though its index already had hits.
|
|
262
|
+
expect(adapter1ChunksLoaded).toBe(true);
|
|
263
|
+
// Adapter 2 also loads every chunk, exposing its hidden hit.
|
|
264
264
|
expect(adapter2ChunksLoaded).toBe(true);
|
|
265
265
|
});
|
|
266
266
|
|
|
@@ -26,7 +26,7 @@ interface IndexJson {
|
|
|
26
26
|
schemaVersion: string;
|
|
27
27
|
conceptCount: number;
|
|
28
28
|
chunkSize: number;
|
|
29
|
-
chunks: { file: string; count: number }[];
|
|
29
|
+
chunks: { file: string; count: number; hash?: string }[];
|
|
30
30
|
concepts: IndexConceptJson[];
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -52,7 +52,7 @@ export class DatasetAdapter {
|
|
|
52
52
|
private summaryMap = new Map<string, ConceptSummary>();
|
|
53
53
|
private designationMap = new Map<string, string>();
|
|
54
54
|
private loadedChunks = new Set<number>();
|
|
55
|
-
private indexMeta:
|
|
55
|
+
private indexMeta: Pick<IndexJson, 'conceptCount' | 'chunkSize' | 'chunks'> | null = null;
|
|
56
56
|
|
|
57
57
|
constructor(registerId: string, baseUrl: string) {
|
|
58
58
|
this.registerId = registerId;
|
|
@@ -152,7 +152,7 @@ export class DatasetAdapter {
|
|
|
152
152
|
|
|
153
153
|
private async loadIndexChunked(): Promise<ConceptIndex> {
|
|
154
154
|
const metaResp = await fetch(`${this.baseUrl}/index-meta.json`);
|
|
155
|
-
let meta:
|
|
155
|
+
let meta: Omit<IndexJson, 'concepts'>;
|
|
156
156
|
if (metaResp.ok) {
|
|
157
157
|
meta = await metaResp.json();
|
|
158
158
|
} else {
|
|
@@ -183,10 +183,17 @@ export class DatasetAdapter {
|
|
|
183
183
|
return this.index;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
private chunkUrl(chunkIndex: number): string {
|
|
187
|
+
const chunks: IndexJson['chunks'] = this.indexMeta?.chunks ?? this.index?.chunks ?? [];
|
|
188
|
+
const chunk = chunks[chunkIndex];
|
|
189
|
+
const file = chunk?.file ?? `index-${String(chunkIndex).padStart(4, '0')}.json`;
|
|
190
|
+
const query = chunk?.hash ? `?v=${encodeURIComponent(chunk.hash)}` : '';
|
|
191
|
+
return `${this.baseUrl}/chunks/${file}${query}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
186
194
|
async loadChunk(chunkIndex: number): Promise<ConceptEntry[]> {
|
|
187
195
|
if (this.loadedChunks.has(chunkIndex)) return [];
|
|
188
|
-
const
|
|
189
|
-
const resp = await fetch(`${this.baseUrl}/chunks/${chunkFile}`);
|
|
196
|
+
const resp = await fetch(this.chunkUrl(chunkIndex));
|
|
190
197
|
if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
|
|
191
198
|
const data = await resp.json();
|
|
192
199
|
this.loadedChunks.add(chunkIndex);
|
|
@@ -196,9 +203,8 @@ export class DatasetAdapter {
|
|
|
196
203
|
private async loadChunkAsSummaries(chunkIndex: number): Promise<void> {
|
|
197
204
|
if (this.loadedChunks.has(chunkIndex)) return;
|
|
198
205
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
if (!resp.ok) return;
|
|
206
|
+
const resp = await fetch(this.chunkUrl(chunkIndex));
|
|
207
|
+
if (!resp.ok) throw ChunkLoadError.make(this.registerId, chunkIndex, resp.status);
|
|
202
208
|
const data = await resp.json();
|
|
203
209
|
this.loadedChunks.add(chunkIndex);
|
|
204
210
|
|
|
@@ -329,6 +335,7 @@ export class DatasetAdapter {
|
|
|
329
335
|
conceptId: entry.id,
|
|
330
336
|
registerId: this.registerId,
|
|
331
337
|
designation: entry.eng || '',
|
|
338
|
+
status: entry.status,
|
|
332
339
|
language: '',
|
|
333
340
|
matchField: 'id',
|
|
334
341
|
snippet: `ID: ${entry.id}`,
|
|
@@ -347,6 +354,7 @@ export class DatasetAdapter {
|
|
|
347
354
|
conceptId: entry.id,
|
|
348
355
|
registerId: this.registerId,
|
|
349
356
|
designation: term,
|
|
357
|
+
status: entry.status,
|
|
350
358
|
language,
|
|
351
359
|
matchField: 'designation',
|
|
352
360
|
_score: score,
|
|
@@ -166,6 +166,7 @@ export interface JsonLdConcept {
|
|
|
166
166
|
'gl:identifier'?: string | number;
|
|
167
167
|
'gl:term'?: string;
|
|
168
168
|
'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
|
|
169
|
+
'gl:source'?: JsonLdSource[];
|
|
169
170
|
'gl:related'?: JsonLdRelated[];
|
|
170
171
|
'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
|
|
171
172
|
'gl:genericRelations'?: JsonLdPartitiveRelation[];
|
|
@@ -623,6 +623,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
|
|
|
623
623
|
term: doc[GL.TERM] ?? null,
|
|
624
624
|
uri: doc['@id'] ?? null,
|
|
625
625
|
localizations: localizations as Record<string, any>,
|
|
626
|
+
sources: (doc[GL.SOURCE] ?? []).map(mapSourceFromJsonLd),
|
|
626
627
|
related,
|
|
627
628
|
partitive_relations: partitiveRelations,
|
|
628
629
|
generic_relations: genericRelations,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Per-register standards.json, keyed by the normalized cited-standard identity. */
|
|
2
|
+
export type StandardsIndex = Record<string, {
|
|
3
|
+
/** Normalized display label from the first authoritative citation encountered. */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Unique concept IDs in dataset encounter order. */
|
|
6
|
+
conceptIds: string[];
|
|
7
|
+
}>;
|
package/src/adapters/types.ts
CHANGED
|
@@ -26,6 +26,7 @@ const footerNav = computed(() => config.value?.footerNav ?? []);
|
|
|
26
26
|
const copyrightOwner = computed(() => config.value?.copyright || '');
|
|
27
27
|
const ownerName = computed(() => config.value?.branding?.ownerName || config.value?.title || '');
|
|
28
28
|
const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
29
|
+
const hostedBy = computed(() => config.value?.branding?.hostedBy as { name?: string; url: string; logo: string } | undefined);
|
|
29
30
|
</script>
|
|
30
31
|
|
|
31
32
|
<template>
|
|
@@ -58,6 +59,12 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
|
|
|
58
59
|
class="hover:text-ink-700 transition-colors"
|
|
59
60
|
>{{ link.label }}</a>
|
|
60
61
|
<span class="text-ink-200">|</span>
|
|
62
|
+
<span v-if="hostedBy" class="text-xs inline-flex items-center gap-1.5">
|
|
63
|
+
<span>Hosted by</span>
|
|
64
|
+
<a :href="hostedBy.url" target="_blank" rel="noopener" class="inline-flex items-center">
|
|
65
|
+
<img :src="hostedBy.logo" :alt="hostedBy.name || 'Host'" class="h-4 w-auto opacity-80" />
|
|
66
|
+
</a>
|
|
67
|
+
</span>
|
|
61
68
|
<span class="text-xs inline-flex items-center gap-1.5">
|
|
62
69
|
<img :src="glossaristLogo" alt="Glossarist" class="w-4 h-4 opacity-80" />
|
|
63
70
|
<a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.message }}</a>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { useRouter } from 'vue-router';
|
|
2
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
3
3
|
import { useUiStore } from '../stores/ui';
|
|
4
4
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
5
|
import { useSiteConfig } from '../config/use-site-config';
|
|
@@ -9,6 +9,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
|
9
9
|
const glossaristLogo = new URL('../assets/glossarist-logo.svg', import.meta.url).href;
|
|
10
10
|
|
|
11
11
|
const router = useRouter();
|
|
12
|
+
const route = useRoute();
|
|
12
13
|
const ui = useUiStore();
|
|
13
14
|
const store = useVocabularyStore();
|
|
14
15
|
const { config: siteConfig, localizedTitle } = useSiteConfig();
|
|
@@ -26,8 +27,8 @@ const currentLangLabel = computed(() => {
|
|
|
26
27
|
function doSearch() {
|
|
27
28
|
const q = searchInput.value.trim();
|
|
28
29
|
if (q) {
|
|
29
|
-
ui.searchQuery = q;
|
|
30
30
|
router.push({ name: 'search', query: { q } });
|
|
31
|
+
searchInput.value = '';
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -85,13 +86,13 @@ onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside))
|
|
|
85
86
|
</button>
|
|
86
87
|
|
|
87
88
|
<!-- Search -->
|
|
88
|
-
<form @submit.prevent="doSearch" class="flex-1 max-w-lg mx-2 sm:mx-4">
|
|
89
|
+
<form v-if="route.name !== 'search'" @submit.prevent="doSearch" class="flex-1 max-w-lg mx-2 sm:mx-4">
|
|
89
90
|
<div class="relative">
|
|
90
91
|
<input
|
|
91
92
|
v-model="searchInput"
|
|
92
93
|
type="text"
|
|
93
|
-
:aria-label="t('search.
|
|
94
|
-
:placeholder="t('search.
|
|
94
|
+
:aria-label="t('search.quickSearch')"
|
|
95
|
+
:placeholder="t('search.quickPlaceholder')"
|
|
95
96
|
class="w-full pl-9 pr-3 py-2 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all"
|
|
96
97
|
/>
|
|
97
98
|
<svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|