@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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/scripts/__tests__/standards-index.test.ts +121 -0
  3. package/scripts/generate-data.js +51 -3
  4. package/scripts/generate-data.ts +37 -3
  5. package/src/__tests__/app-footer.test.ts +36 -0
  6. package/src/__tests__/app-header.test.ts +32 -5
  7. package/src/__tests__/composables/use-same-standard-terms.test.ts +397 -0
  8. package/src/__tests__/concept-view.test.ts +103 -0
  9. package/src/__tests__/dataset-adapter.test.ts +150 -0
  10. package/src/__tests__/dataset-view.test.ts +41 -1
  11. package/src/__tests__/model-bridge-citation.test.ts +20 -0
  12. package/src/__tests__/same-standard-pane.test.ts +174 -0
  13. package/src/__tests__/scripts/generate-data.test.ts +74 -0
  14. package/src/__tests__/search-across-datasets.test.ts +102 -0
  15. package/src/__tests__/search-bar.test.ts +304 -0
  16. package/src/__tests__/search-results.test.ts +54 -0
  17. package/src/__tests__/search-utils.test.ts +64 -1
  18. package/src/__tests__/search-view.test.ts +194 -2
  19. package/src/__tests__/source-standard.test.ts +70 -0
  20. package/src/__tests__/ui-store.test.ts +26 -0
  21. package/src/__tests__/vocabulary-layered.test.ts +7 -7
  22. package/src/adapters/DatasetAdapter.ts +16 -8
  23. package/src/adapters/jsonld-types.ts +1 -0
  24. package/src/adapters/model-bridge.ts +1 -0
  25. package/src/adapters/standards-index.ts +7 -0
  26. package/src/adapters/types.ts +1 -0
  27. package/src/components/AppFooter.vue +7 -0
  28. package/src/components/AppHeader.vue +6 -5
  29. package/src/components/SameStandardPane.vue +70 -0
  30. package/src/components/SearchBar.vue +73 -18
  31. package/src/components/SearchResults.vue +46 -5
  32. package/src/components/site/SiteFooter.astro +6 -0
  33. package/src/composables/use-same-standard-terms.ts +106 -0
  34. package/src/config/types.ts +11 -0
  35. package/src/config/use-site-config.ts +1 -0
  36. package/src/i18n/locales/eng.yml +10 -1
  37. package/src/i18n/locales/fra.yml +10 -1
  38. package/src/stores/ui.ts +16 -1
  39. package/src/stores/vocabulary.ts +27 -31
  40. package/src/utils/search.ts +14 -1
  41. package/src/utils/source-standard.ts +34 -0
  42. package/src/views/ConceptView.vue +43 -12
  43. package/src/views/DatasetView.vue +25 -5
  44. package/src/views/SearchView.vue +9 -12
@@ -1,10 +1,11 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { mount, flushPromises } from '@vue/test-utils';
3
3
  import { createPinia, setActivePinia } from 'pinia';
4
4
  import { createRouter, createMemoryHistory } from 'vue-router';
5
5
  import DatasetView from '../views/DatasetView.vue';
6
6
  import { useVocabularyStore } from '../stores/vocabulary';
7
7
  import type { Manifest, ConceptSummary, SectionNode } from '../adapters/types';
8
+ import { ChunkLoadError } from '../errors';
8
9
 
9
10
  function makeManifest(overrides: Partial<Manifest> = {}): Manifest {
10
11
  return {
@@ -137,6 +138,45 @@ describe('DatasetView', () => {
137
138
  expect(wrapper.text()).toContain('Next');
138
139
  });
139
140
 
141
+ it.each(['page', 'filter'] as const)('shows a chunk failure instead of remaining loading after a %s change', async (action) => {
142
+ const store = useVocabularyStore();
143
+ const adapter = makeAdapter(makeConcepts(120));
144
+ const error = ChunkLoadError.make('test', 1, 503);
145
+ let failing = true;
146
+ adapter.isRangeLoaded = (start: number) => start === 0;
147
+ adapter.ensureChunksForRange = vi.fn(async () => { if (failing) throw error; });
148
+ adapter.ensureAllChunksLoaded = vi.fn(async () => { if (failing) throw error; });
149
+ store.manifests.set('test', makeManifest());
150
+ store.datasets.set('test', adapter);
151
+ const wrapper = mount(DatasetView, {
152
+ global: { plugins: [pinia, router] },
153
+ props: { registerId: 'test' },
154
+ });
155
+ await flushPromises();
156
+ expect(wrapper.text()).toContain('term 1');
157
+
158
+ if (action === 'page') {
159
+ await wrapper.findAll('button').find(b => b.text().includes('Next'))!.trigger('click');
160
+ } else {
161
+ await wrapper.find('input[aria-label="Filter concepts"]').setValue('term');
162
+ }
163
+ await flushPromises();
164
+
165
+ expect(wrapper.text()).toContain(error.message);
166
+ expect(wrapper.findAll('button').some(b => b.text() === 'Retry')).toBe(true);
167
+ expect(wrapper.find('.skeleton').exists()).toBe(false);
168
+ expect((wrapper.vm as unknown as { chunkLoading: boolean }).chunkLoading).toBe(false);
169
+
170
+ failing = false;
171
+ await wrapper.findAll('button').find(b => b.text() === 'Retry')!.trigger('click');
172
+ await flushPromises();
173
+ expect(wrapper.text()).not.toContain(error.message);
174
+ expect(wrapper.text()).toContain(action === 'page' ? 'term 51' : 'term 1');
175
+ expect(wrapper.find('.skeleton').exists()).toBe(false);
176
+ expect(action === 'page' ? adapter.ensureChunksForRange : adapter.ensureAllChunksLoaded).toHaveBeenCalledTimes(2);
177
+ wrapper.unmount();
178
+ });
179
+
140
180
  it('shows correct page count in pagination', async () => {
141
181
  const wrapper = mountDataset(makeConcepts(120));
142
182
  await flushPromises();
@@ -2,6 +2,26 @@ import { describe, it, expect } from 'vitest';
2
2
  import { conceptFromJson, getRelatedSourceId, getRelatedCitation } from '../adapters/model-bridge';
3
3
 
4
4
  describe('model-bridge — source id mapping', () => {
5
+ it('preserves concept-level sources alongside localized sources', () => {
6
+ const source = (id: string, type = 'authoritative') => ({
7
+ 'gl:sourceType': type,
8
+ 'gl:origin': { 'gl:ref': { 'gl:source': 'ISO', 'gl:id': id } },
9
+ });
10
+ const concept = conceptFromJson({
11
+ '@type': 'gl:Concept',
12
+ 'gl:identifier': '1',
13
+ 'gl:source': [source('19165-2:2020'), source('704:2022', 'lineage')],
14
+ 'gl:localizedConcept': { eng: { 'gl:source': [source('1087:2019')] } },
15
+ });
16
+
17
+ expect(concept.sources.map(s => [s.type, s.origin?.ref?.source, s.origin?.ref?.id])).toEqual([
18
+ ['authoritative', 'ISO', '19165-2:2020'],
19
+ ['lineage', 'ISO', '704:2022'],
20
+ ]);
21
+ expect(concept.localization('eng')!.sources.map(s => s.origin?.ref?.id)).toEqual(['1087:2019']);
22
+ expect(conceptFromJson({ '@type': 'gl:Concept', 'gl:identifier': '2' }).sources).toEqual([]);
23
+ });
24
+
5
25
  it('maps gl:id from JSON-LD ConceptSource', () => {
6
26
  const doc = {
7
27
  '@type': 'gl:Concept',
@@ -0,0 +1,174 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { DatasetAdapter } from '../adapters/DatasetAdapter';
6
+ import { collectStandardKeys } from '../utils/source-standard';
7
+ import { useSameStandardTerms } from '../composables/use-same-standard-terms';
8
+ import { useVocabularyStore } from '../stores/vocabulary';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { flushPromises, mount } from '@vue/test-utils';
11
+ import { RouterLink } from 'vue-router';
12
+ import SameStandardPane from '../components/SameStandardPane.vue';
13
+ import type { SameStandardTerms } from '../composables/use-same-standard-terms';
14
+ import { createTestRouter, setupPinia } from './test-helpers';
15
+
16
+ const groups: SameStandardTerms[] = [
17
+ {
18
+ key: 'iso-123', label: 'ISO 123:2024', terms: [
19
+ { conceptId: 'current', designation: 'Current term' },
20
+ { conceptId: 'other', designation: 'Related term' },
21
+ { conceptId: 'third', designation: 'Third term' },
22
+ ],
23
+ },
24
+ { key: 'iec-456', label: 'IEC 456:2020', terms: [{ conceptId: 'fourth', designation: 'Fourth term' }] },
25
+ ];
26
+
27
+ describe('SameStandardPane', () => {
28
+ let pinia: ReturnType<typeof setupPinia>;
29
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
30
+
31
+ beforeEach(async () => {
32
+ localStorage.clear();
33
+ pinia = setupPinia();
34
+ router = await createTestRouter('dataset');
35
+ });
36
+
37
+ afterEach(() => vi.restoreAllMocks());
38
+
39
+ function mountPane(paneGroups = groups) {
40
+ return mount(SameStandardPane, {
41
+ props: { groups: paneGroups, registerId: 'test-register', conceptId: 'current' },
42
+ global: { plugins: [pinia, router] },
43
+ });
44
+ }
45
+
46
+ it('renders peers from generated concept-level sources in a 1200-concept register', async () => {
47
+ const root = mkdtempSync(join(tmpdir(), 'concept-source-pane-'));
48
+ let wrapper: ReturnType<typeof mount> | undefined;
49
+ try {
50
+ const conceptsDir = join(root, 'source', 'concepts');
51
+ const configPath = join(root, 'site-config.yml');
52
+ mkdirSync(conceptsDir, { recursive: true });
53
+ writeFileSync(configPath, JSON.stringify({
54
+ id: 'fixture', title: 'Fixture', uri_base: 'https://example.test',
55
+ datasets: [{ id: 'fixture', local_path: 'source', languages: ['eng'] }],
56
+ }));
57
+ for (let id = 1; id <= 1200; id++) {
58
+ writeFileSync(join(conceptsDir, `${id}.yaml`), JSON.stringify({
59
+ termid: String(id),
60
+ _sources: [1, 600, 1100].includes(id)
61
+ ? [{ type: 'authoritative', origin: { ref: { source: 'ISO', id: '19165-2:2020' } } }]
62
+ : [],
63
+ eng: { terms: [{ designation: `Fixture term ${id}` }] },
64
+ }));
65
+ }
66
+ execFileSync(process.execPath, [
67
+ '--import', resolve('node_modules/tsx/dist/loader.mjs'), resolve('scripts/generate-data.ts'),
68
+ ], { cwd: root, env: { ...process.env, SITE_CONFIG: configPath }, stdio: 'pipe', timeout: 30_000 });
69
+ const readJson = (url: string) => JSON.parse(readFileSync(join(root, 'public', url), 'utf8'));
70
+ expect(readJson('/data/fixture/standards.json')).toEqual({
71
+ 'iso 19165-2:2020': { label: 'ISO 19165-2:2020', conceptIds: ['1', '600', '1100'] },
72
+ });
73
+ vi.stubGlobal('fetch', vi.fn(async (url: string) => ({ ok: true, json: async () => readJson(url) })));
74
+ const adapter = new DatasetAdapter('fixture', '/data/fixture');
75
+ await adapter.loadIndex();
76
+ const concept = await adapter.fetchConcept('1');
77
+ expect(concept.sources).toHaveLength(1);
78
+ expect(concept.localization('eng')!.sources).toEqual([]);
79
+ expect(collectStandardKeys(concept)).toEqual(['iso 19165-2:2020']);
80
+ const store = useVocabularyStore();
81
+ store.datasets.set('fixture', adapter);
82
+ store.currentRegisterId = 'fixture';
83
+ store.currentConcept = concept;
84
+ wrapper = mount({
85
+ components: { SameStandardPane },
86
+ setup: () => ({ groups: useSameStandardTerms('/') }),
87
+ template: '<SameStandardPane :groups="groups" register-id="fixture" concept-id="1" />',
88
+ }, { global: { plugins: [pinia, router] } });
89
+ await vi.waitFor(() => expect(wrapper!.findAll('a').map(link => link.text())).toEqual([
90
+ 'Fixture term 600', 'Fixture term 1100',
91
+ ]));
92
+ expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual([
93
+ '/dataset/fixture/concept/600', '/dataset/fixture/concept/1100',
94
+ ]);
95
+ expect(wrapper.get('h3').text()).toContain('ISO 19165-2:2020');
96
+ } finally {
97
+ wrapper?.unmount();
98
+ vi.unstubAllGlobals();
99
+ rmSync(root, { recursive: true, force: true });
100
+ }
101
+ }, 60_000);
102
+
103
+ it('groups terms with filtered counts and named links in the current register', async () => {
104
+ const wrapper = mountPane();
105
+ const sections = wrapper.findAll('section');
106
+ expect(sections).toHaveLength(2);
107
+ expect(sections[0].find('h3').text()).toContain('ISO 123:2024');
108
+ expect(sections[0].find('h3').text()).toContain('(2)');
109
+ expect(sections[1].find('h3').text()).toContain('IEC 456:2020');
110
+ expect(sections[1].find('h3').text()).toContain('(1)');
111
+ expect(wrapper.text()).not.toContain('Current term');
112
+ const links = wrapper.findAllComponents(RouterLink);
113
+ expect(links.map(link => link.props('to'))).toEqual([
114
+ { name: 'concept', params: { registerId: 'test-register', conceptId: 'other' } },
115
+ { name: 'concept', params: { registerId: 'test-register', conceptId: 'third' } },
116
+ { name: 'concept', params: { registerId: 'test-register', conceptId: 'fourth' } },
117
+ ]);
118
+ expect(wrapper.findAll('a').map(link => link.attributes('href'))).toEqual([
119
+ '/dataset/test-register/concept/other',
120
+ '/dataset/test-register/concept/third',
121
+ '/dataset/test-register/concept/fourth',
122
+ ]);
123
+ expect(links[0].text()).toBe('Related term');
124
+ await links[0].trigger('click');
125
+ await flushPromises();
126
+ expect(router.currentRoute.value.params).toEqual({ registerId: 'test-register', conceptId: 'other' });
127
+ });
128
+
129
+ it('renders nothing for missing groups or groups containing only the current concept', () => {
130
+ expect(mountPane([]).find('aside').exists()).toBe(false);
131
+ expect(mountPane([{ key: 'empty', label: 'Empty standard', terms: [] }]).find('aside').exists()).toBe(false);
132
+ expect(mountPane([{ ...groups[0], terms: [groups[0].terms[0]] }]).find('aside').exists()).toBe(false);
133
+ });
134
+
135
+ it('uses a native accessible toggle and persists collapsed state across remounts', async () => {
136
+ const wrapper = mountPane();
137
+ const button = wrapper.get('button');
138
+ expect(button.attributes('type')).toBe('button');
139
+ expect(button.attributes('aria-expanded')).toBe('true');
140
+ const target = wrapper.get(`#${button.attributes('aria-controls')}`);
141
+ expect(target.isVisible()).toBe(true);
142
+ await button.trigger('click');
143
+ expect(button.attributes('aria-expanded')).toBe('false');
144
+ expect(target.isVisible()).toBe(false);
145
+ wrapper.unmount();
146
+
147
+ const remounted = mountPane();
148
+ expect(remounted.get('button').attributes('aria-expanded')).toBe('false');
149
+ await remounted.get('button').trigger('click');
150
+ expect(remounted.get(`#${remounted.get('button').attributes('aria-controls')}`).isVisible()).toBe(true);
151
+ remounted.unmount();
152
+ expect(mountPane().get('button').attributes('aria-expanded')).toBe('true');
153
+ });
154
+
155
+ it('gives each pane its own controlled region', () => {
156
+ const wrapper = mount({
157
+ components: { SameStandardPane },
158
+ setup: () => ({ groups }),
159
+ template: '<div><SameStandardPane :groups="groups" register-id="test" concept-id="current" /><SameStandardPane :groups="groups" register-id="test" concept-id="current" /></div>',
160
+ }, { global: { plugins: [pinia, router] } });
161
+ const [first, second] = wrapper.findAll('button').map(button => button.attributes('aria-controls'));
162
+ expect(first).toBeTruthy();
163
+ expect(second).not.toBe(first);
164
+ });
165
+
166
+ it('continues toggling when storage reads or writes fail', async () => {
167
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Unavailable'); });
168
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Unavailable'); });
169
+ const wrapper = mountPane();
170
+ expect(wrapper.get('button').attributes('aria-expanded')).toBe('true');
171
+ await wrapper.get('button').trigger('click');
172
+ expect(wrapper.get('button').attributes('aria-expanded')).toBe('false');
173
+ });
174
+ });
@@ -0,0 +1,74 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it } from 'vitest';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+
9
+ interface Chunk {
10
+ file: string;
11
+ count: number;
12
+ hash: string;
13
+ }
14
+
15
+ describe('generate-data chunk hashes', () => {
16
+ it('writes content hashes to both indexes and changes only the hash of modified chunks', () => {
17
+ const root = mkdtempSync(join(tmpdir(), 'generate-data-hashes-'));
18
+ const conceptsDir = join(root, 'source', 'concepts');
19
+ const outputDir = join(root, 'public', 'data', 'fixture');
20
+ const configPath = join(root, 'site-config.yml');
21
+ const generator = resolve('scripts/generate-data.ts');
22
+ const tsx = resolve('node_modules/tsx/dist/loader.mjs');
23
+
24
+ const writeConcept = (id: number, designation: string) => {
25
+ writeFileSync(join(conceptsDir, `${id}.yaml`), JSON.stringify({
26
+ termid: String(id),
27
+ eng: { terms: [{ designation }], entry_status: 'valid' },
28
+ }));
29
+ };
30
+ const generate = () => {
31
+ execFileSync(process.execPath, ['--import', tsx, generator], {
32
+ cwd: root,
33
+ env: { ...process.env, SITE_CONFIG: configPath },
34
+ stdio: 'pipe',
35
+ timeout: 30_000,
36
+ });
37
+ const indexes = ['index.json', 'index-meta.json'].map(file =>
38
+ JSON.parse(readFileSync(join(outputDir, file), 'utf8')),
39
+ );
40
+ for (const index of indexes) {
41
+ expect(index.conceptCount).toBe(501);
42
+ expect(index.chunks.map((chunk: Chunk) => chunk.count)).toEqual([500, 1]);
43
+ for (const chunk of index.chunks as Chunk[]) {
44
+ expect(chunk.hash).toMatch(/^[a-f0-9]{12}$/);
45
+ const content = readFileSync(join(outputDir, 'chunks', chunk.file));
46
+ expect(chunk.hash).toBe(createHash('sha256').update(content).digest('hex').slice(0, 12));
47
+ }
48
+ }
49
+ expect(indexes[1].chunks).toEqual(indexes[0].chunks);
50
+ return indexes[0].chunks as Chunk[];
51
+ };
52
+
53
+ try {
54
+ mkdirSync(conceptsDir, { recursive: true });
55
+ writeFileSync(configPath, JSON.stringify({
56
+ id: 'fixture', title: 'Fixture', uri_base: 'https://example.test',
57
+ datasets: [{ id: 'fixture', local_path: 'source', languages: ['eng'] }],
58
+ }));
59
+ for (let id = 1; id <= 501; id++) writeConcept(id, `Term ${id}`);
60
+
61
+ const initial = generate();
62
+ expect(generate()).toEqual(initial);
63
+
64
+ writeConcept(1, 'Updated term');
65
+ const changed = generate();
66
+ expect(changed[0]!.hash).not.toBe(initial[0]!.hash);
67
+ expect(changed[1]).toEqual(initial[1]);
68
+ const chunk = JSON.parse(readFileSync(join(outputDir, 'chunks', changed[0]!.file), 'utf8'));
69
+ expect(chunk.concepts[0].designations.eng).toBe('Updated term');
70
+ } finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ }, 60_000);
74
+ });
@@ -0,0 +1,102 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { useVocabularyStore } from '../stores/vocabulary';
4
+ import type { DatasetAdapter } from '../adapters/DatasetAdapter';
5
+ import type { Manifest, SearchHit } from '../adapters/types';
6
+
7
+ const discoverDatasets = vi.fn();
8
+ vi.mock('../adapters/factory', () => ({
9
+ getFactory: () => ({ discoverDatasets, getAdapters: () => [], loadDataset: vi.fn() }),
10
+ }));
11
+
12
+ function adapter(registerId: string, indexRows: SearchHit[], laterRows: SearchHit[] = [], options: { failLoad?: boolean; manifest?: boolean } = {}) {
13
+ const index = { concepts: [...indexRows] };
14
+ const search = vi.fn((query: string) => index.concepts
15
+ .filter((row) => row.designation.toLowerCase().includes(query.toLowerCase())));
16
+ const ensureAllChunksLoaded = vi.fn(async () => {
17
+ await Promise.resolve();
18
+ if (options.failLoad) throw new Error('chunk unavailable');
19
+ index.concepts.push(...laterRows);
20
+ });
21
+ return {
22
+ adapter: {
23
+ registerId,
24
+ manifest: options.manifest === false ? null : { id: registerId } as Manifest,
25
+ index,
26
+ loadIndex: vi.fn(async () => undefined),
27
+ search,
28
+ ensureAllChunksLoaded,
29
+ } as unknown as DatasetAdapter,
30
+ search,
31
+ ensureAllChunksLoaded,
32
+ };
33
+ }
34
+
35
+ const hit = (registerId: string, conceptId: string, designation: string, status = 'valid'): SearchHit => ({
36
+ registerId, conceptId, designation, status, language: 'eng', matchField: 'designation',
37
+ });
38
+
39
+ describe('searchAcrossDatasets', () => {
40
+ beforeEach(() => {
41
+ setActivePinia(createPinia());
42
+ discoverDatasets.mockReset();
43
+ });
44
+
45
+ it('returns both location concepts on first and repeated searches', async () => {
46
+ const large = adapter('large', [hit('large', '276', 'location', 'superseded'), hit('large', '999', 'place-name')], [hit('large', '2345', 'location'), hit('large', '1280', 'place')]);
47
+ const other = adapter('other', [], [hit('other', '1280', 'place')]);
48
+ discoverDatasets.mockResolvedValue([large.adapter, other.adapter]);
49
+ const store = useVocabularyStore();
50
+ const locationHits = await store.searchAcrossDatasets('location');
51
+ expect(locationHits.map(({ conceptId, status }) => [conceptId, status])).toEqual([['276', 'superseded'], ['2345', 'valid']]);
52
+ const repeatedLocationHits = await store.searchAcrossDatasets('location');
53
+ expect(repeatedLocationHits.map(({ conceptId, status }) => [conceptId, status])).toEqual([['276', 'superseded'], ['2345', 'valid']]);
54
+ expect(large.ensureAllChunksLoaded).toHaveBeenCalledTimes(2);
55
+
56
+ });
57
+
58
+ it('finds later place concepts even when chunk zero has a matching term', async () => {
59
+ const freshLarge = adapter('large', [hit('large', '999', 'place-name')], [hit('large', '1280', 'place')]);
60
+ const freshOther = adapter('other', [], [hit('other', '1280', 'place')]);
61
+ discoverDatasets.mockResolvedValue([freshLarge.adapter, freshOther.adapter]);
62
+ const placeHits = await useVocabularyStore().searchAcrossDatasets('place');
63
+ expect(placeHits.map(({ registerId, conceptId }) => [registerId, conceptId])).toEqual([['large', '999'], ['large', '1280'], ['other', '1280']]);
64
+ });
65
+
66
+ it('preserves loaded hits when a later chunk fails and continues searching other datasets', async () => {
67
+ const loadedHit = hit('partial', '276', 'location', 'superseded');
68
+ const partial = adapter('partial', [loadedHit], [], { failLoad: true });
69
+ const healthyHit = hit('healthy', '2345', 'location');
70
+ const healthy = adapter('healthy', [], [healthyHit]);
71
+ discoverDatasets.mockResolvedValue([partial.adapter, healthy.adapter]);
72
+
73
+ const store = useVocabularyStore();
74
+ const results = await store.searchAcrossDatasets('location');
75
+
76
+ expect(partial.ensureAllChunksLoaded).toHaveBeenCalledOnce();
77
+ expect(results).toEqual([loadedHit, healthyHit]);
78
+ expect(store.searchWarning).toBe('search.incomplete');
79
+
80
+ partial.ensureAllChunksLoaded.mockResolvedValue(undefined);
81
+ await store.searchAcrossDatasets('location');
82
+ expect(store.searchWarning).toBeNull();
83
+ });
84
+
85
+ it('continues after more than 20 hits and isolates chunk load and search failures', async () => {
86
+ const crowdedRows = Array.from({ length: 21 }, (_, i) => hit('crowded', `needle-${i}`, 'needle'));
87
+ const crowded = adapter('crowded', crowdedRows);
88
+ const late = adapter('late', [], [hit('late', 'late-1', 'needle')]);
89
+ const brokenLoad = adapter('broken-load', [], [], { failLoad: true });
90
+ const noManifest = adapter('no-manifest', [], [hit('no-manifest', 'skip', 'needle')], { manifest: false });
91
+ const brokenSearch = adapter('broken-search', []);
92
+ brokenSearch.search.mockImplementation(() => { throw new Error('search failed'); });
93
+ discoverDatasets.mockResolvedValue([crowded.adapter, brokenLoad.adapter, noManifest.adapter, brokenSearch.adapter, late.adapter]);
94
+
95
+ const results = await useVocabularyStore().searchAcrossDatasets('needle');
96
+ expect(results.map(({ registerId, conceptId }) => [registerId, conceptId])).toContainEqual(['late', 'late-1']);
97
+ expect(results).toHaveLength(22);
98
+ expect(late.ensureAllChunksLoaded).toHaveBeenCalledOnce();
99
+ expect(brokenLoad.ensureAllChunksLoaded).toHaveBeenCalledOnce();
100
+ expect(noManifest.ensureAllChunksLoaded).not.toHaveBeenCalled();
101
+ });
102
+ });