@glossarist/concept-browser 0.3.4 → 0.4.0

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 (81) hide show
  1. package/README.md +3 -2
  2. package/cli/index.mjs +2 -1
  3. package/env.d.ts +5 -0
  4. package/package.json +4 -3
  5. package/scripts/build-edges.js +78 -10
  6. package/scripts/generate-data.mjs +152 -20
  7. package/scripts/generate-ontology-data.mjs +184 -0
  8. package/scripts/generate-ontology-schema.mjs +315 -0
  9. package/src/__tests__/about-view.test.ts +98 -0
  10. package/src/__tests__/app-footer.test.ts +38 -0
  11. package/src/__tests__/app-header.test.ts +130 -0
  12. package/src/__tests__/app-sidebar.test.ts +159 -0
  13. package/src/__tests__/asciidoc-lite.test.ts +1 -1
  14. package/src/__tests__/concept-card.test.ts +115 -0
  15. package/src/__tests__/concept-detail-interaction.test.ts +273 -0
  16. package/src/__tests__/concept-formats.test.ts +32 -30
  17. package/src/__tests__/concept-timeline.test.ts +200 -0
  18. package/src/__tests__/concept-view.test.ts +88 -0
  19. package/src/__tests__/contributors-view.test.ts +103 -0
  20. package/src/__tests__/dataset-adapter.test.ts +172 -23
  21. package/src/__tests__/dataset-view.test.ts +232 -0
  22. package/src/__tests__/designation-registry.test.ts +161 -0
  23. package/src/__tests__/format-downloads.test.ts +98 -0
  24. package/src/__tests__/graph-view.test.ts +69 -0
  25. package/src/__tests__/graph.test.ts +62 -0
  26. package/src/__tests__/home-interaction.test.ts +157 -0
  27. package/src/__tests__/language-detail.test.ts +203 -0
  28. package/src/__tests__/nav-icon.test.ts +48 -0
  29. package/src/__tests__/news-view.test.ts +87 -0
  30. package/src/__tests__/ontology-registry.test.ts +109 -0
  31. package/src/__tests__/page-view.test.ts +83 -0
  32. package/src/__tests__/relationship-categories.test.ts +62 -0
  33. package/src/__tests__/resolve-view.test.ts +77 -0
  34. package/src/__tests__/router.test.ts +65 -0
  35. package/src/__tests__/search-bar.test.ts +219 -0
  36. package/src/__tests__/search-view.test.ts +41 -0
  37. package/src/__tests__/stats-view.test.ts +77 -0
  38. package/src/__tests__/test-helpers.ts +171 -0
  39. package/src/__tests__/ui-store.test.ts +100 -0
  40. package/src/__tests__/v-math.test.ts +8 -7
  41. package/src/adapters/DatasetAdapter.ts +188 -63
  42. package/src/adapters/model-bridge.ts +277 -0
  43. package/src/adapters/ontology-registry.ts +75 -0
  44. package/src/adapters/ontology-schema.ts +100 -0
  45. package/src/adapters/types.ts +53 -78
  46. package/src/components/AppSidebar.vue +1 -1
  47. package/src/components/CitationDisplay.vue +35 -0
  48. package/src/components/ConceptDetail.vue +349 -146
  49. package/src/components/ConceptRdfView.vue +397 -0
  50. package/src/components/ConceptTimeline.vue +57 -60
  51. package/src/components/GraphPanel.vue +96 -31
  52. package/src/components/LanguageDetail.vue +46 -61
  53. package/src/components/NavIcon.vue +1 -0
  54. package/src/components/NonVerbalRepDisplay.vue +38 -0
  55. package/src/components/RelationshipList.vue +99 -0
  56. package/src/composables/use-render-options.ts +1 -4
  57. package/src/config/use-site-config.ts +3 -0
  58. package/src/data/ontology-schema.json +1551 -0
  59. package/src/data/taxonomies.json +543 -0
  60. package/src/graph/GraphEngine.ts +7 -4
  61. package/src/router/index.ts +6 -1
  62. package/src/shims/empty.ts +1 -0
  63. package/src/shims/node-crypto.ts +6 -0
  64. package/src/shims/node-path.ts +10 -0
  65. package/src/stores/vocabulary.ts +82 -32
  66. package/src/style.css +74 -20
  67. package/src/utils/asciidoc-lite.ts +17 -19
  68. package/src/utils/concept-formats.ts +22 -20
  69. package/src/utils/concept-helpers.ts +54 -0
  70. package/src/utils/designation-registry.ts +124 -0
  71. package/src/utils/escape.ts +7 -0
  72. package/src/utils/markdown-lite.ts +1 -3
  73. package/src/utils/math.ts +2 -11
  74. package/src/utils/plurimath.ts +2 -7
  75. package/src/utils/relationship-categories.ts +84 -0
  76. package/src/views/ConceptView.vue +22 -1
  77. package/src/views/DatasetView.vue +7 -2
  78. package/src/views/OntologySchemaView.vue +302 -0
  79. package/src/views/PageView.vue +28 -17
  80. package/src/views/StatsView.vue +34 -12
  81. package/vite.config.ts +8 -0
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { DatasetAdapter } from '../adapters/DatasetAdapter';
3
+ import { conceptFromJson } from '../adapters/model-bridge';
3
4
 
4
5
  // Mock fetch globally
5
6
  const mockFetch = vi.fn();
@@ -98,13 +99,13 @@ describe('DatasetAdapter', () => {
98
99
  mockFetch.mockReturnValue(mockJsonResponse(concept));
99
100
 
100
101
  const result = await adapter.fetchConcept('103-01-02');
101
- expect(result['gl:identifier']).toBe('103-01-02');
102
+ expect(result.id).toBe('103-01-02');
102
103
  expect(mockFetch).toHaveBeenCalledWith('/data/test/concepts/103-01-02.json');
103
104
 
104
105
  // Second call should use cache
105
106
  mockFetch.mockReset();
106
107
  const cached = await adapter.fetchConcept('103-01-02');
107
- expect(cached['gl:identifier']).toBe('103-01-02');
108
+ expect(cached.id).toBe('103-01-02');
108
109
  expect(mockFetch).not.toHaveBeenCalled();
109
110
  });
110
111
 
@@ -195,12 +196,56 @@ describe('DatasetAdapter', () => {
195
196
  const hits = adapter.search('xyznotfound');
196
197
  expect(hits.length).toBe(0);
197
198
  });
199
+
200
+ it('ranks exact matches above starts-with above contains', async () => {
201
+ const index = {
202
+ registerId: 'test',
203
+ schemaVersion: '1.0.0',
204
+ conceptCount: 3,
205
+ chunkSize: 500,
206
+ chunks: [],
207
+ concepts: [
208
+ { id: '1', designations: { eng: 'mass' }, eng: 'mass', status: 'valid' },
209
+ { id: '2', designations: { eng: 'mass flow rate' }, eng: 'mass flow rate', status: 'valid' },
210
+ { id: '3', designations: { eng: 'center of mass' }, eng: 'center of mass', status: 'valid' },
211
+ ],
212
+ };
213
+ mockFetch.mockReturnValue(mockJsonResponse(index));
214
+ await adapter.loadIndex();
215
+
216
+ const hits = adapter.search('mass');
217
+ // Exact match first, then starts-with, then contains
218
+ expect(hits[0].conceptId).toBe('1');
219
+ expect(hits[1].conceptId).toBe('2');
220
+ expect(hits[2].conceptId).toBe('3');
221
+ });
222
+
223
+ it('ranks ID exact match highest', async () => {
224
+ const index = {
225
+ registerId: 'test',
226
+ schemaVersion: '1.0.0',
227
+ conceptCount: 2,
228
+ chunkSize: 500,
229
+ chunks: [],
230
+ concepts: [
231
+ { id: '102-01-01', designations: { eng: 'field' }, eng: 'field', status: 'valid' },
232
+ { id: '102-01-02', designations: { eng: 'electromagnetic field' }, eng: 'electromagnetic field', status: 'valid' },
233
+ ],
234
+ };
235
+ mockFetch.mockReturnValue(mockJsonResponse(index));
236
+ await adapter.loadIndex();
237
+
238
+ const hits = adapter.search('102-01-01');
239
+ expect(hits[0].conceptId).toBe('102-01-01');
240
+ expect(hits[0].matchField).toBe('id');
241
+ });
198
242
  });
199
243
 
200
244
  describe('extractEdges', () => {
201
245
  it('extracts cross-reference edges from gl:references', () => {
202
- const concept = {
246
+ const concept = conceptFromJson({
203
247
  '@id': 'https://glossarist.org/test/concept/102-01-01',
248
+ '@type': 'gl:Concept',
204
249
  'gl:localizedConcept': {
205
250
  eng: {
206
251
  'gl:references': [
@@ -209,18 +254,38 @@ describe('DatasetAdapter', () => {
209
254
  ],
210
255
  },
211
256
  },
212
- };
257
+ });
213
258
 
214
- const edges = adapter.extractEdges(concept as any);
259
+ const edges = adapter.extractEdges(concept);
215
260
  expect(edges.length).toBe(2);
216
261
  expect(edges[0].target).toBe('https://glossarist.org/iev/concept/103-01-02');
217
262
  expect(edges[0].type).toBe('references');
218
263
  expect(edges[0].label).toBe('functional');
219
264
  });
220
265
 
266
+ it('tags reference edges with language', () => {
267
+ const concept = conceptFromJson({
268
+ '@id': 'https://glossarist.org/test/concept/1',
269
+ '@type': 'gl:Concept',
270
+ 'gl:localizedConcept': {
271
+ eng: { 'gl:references': [
272
+ { '@id': 'https://glossarist.org/test/concept/2', 'gl:term': 'other' },
273
+ ]},
274
+ fra: { 'gl:references': [
275
+ { '@id': 'https://glossarist.org/test/concept/3', 'gl:term': 'autre' },
276
+ ]},
277
+ },
278
+ });
279
+ const edges = adapter.extractEdges(concept);
280
+ expect(edges.length).toBe(2);
281
+ expect(edges.find(e => e.lang === 'eng')?.target).toContain('/concept/2');
282
+ expect(edges.find(e => e.lang === 'fra')?.target).toContain('/concept/3');
283
+ });
284
+
221
285
  it('skips self-references', () => {
222
- const concept = {
286
+ const concept = conceptFromJson({
223
287
  '@id': 'https://glossarist.org/test/concept/102-01-01',
288
+ '@type': 'gl:Concept',
224
289
  'gl:localizedConcept': {
225
290
  eng: {
226
291
  'gl:references': [
@@ -228,37 +293,40 @@ describe('DatasetAdapter', () => {
228
293
  ],
229
294
  },
230
295
  },
231
- };
296
+ });
232
297
 
233
- const edges = adapter.extractEdges(concept as any);
298
+ const edges = adapter.extractEdges(concept);
234
299
  expect(edges.length).toBe(0);
235
300
  });
236
301
 
237
302
  it('handles concepts with no references', () => {
238
- const concept = {
303
+ const concept = conceptFromJson({
239
304
  '@id': 'https://glossarist.org/test/concept/102-01-01',
305
+ '@type': 'gl:Concept',
240
306
  'gl:localizedConcept': {
241
307
  eng: {},
242
308
  },
243
- };
309
+ });
244
310
 
245
- const edges = adapter.extractEdges(concept as any);
311
+ const edges = adapter.extractEdges(concept);
246
312
  expect(edges.length).toBe(0);
247
313
  });
248
314
 
249
315
  it('handles empty localizedConcept', () => {
250
- const concept = {
316
+ const concept = conceptFromJson({
251
317
  '@id': 'https://glossarist.org/test/concept/102-01-01',
318
+ '@type': 'gl:Concept',
252
319
  'gl:localizedConcept': {},
253
- };
320
+ });
254
321
 
255
- const edges = adapter.extractEdges(concept as any);
322
+ const edges = adapter.extractEdges(concept);
256
323
  expect(edges.length).toBe(0);
257
324
  });
258
325
 
259
326
  it('collects references from multiple languages without duplication', () => {
260
- const concept = {
327
+ const concept = conceptFromJson({
261
328
  '@id': 'https://glossarist.org/test/concept/102-01-01',
329
+ '@type': 'gl:Concept',
262
330
  'gl:localizedConcept': {
263
331
  eng: {
264
332
  'gl:references': [
@@ -271,16 +339,17 @@ describe('DatasetAdapter', () => {
271
339
  ],
272
340
  },
273
341
  },
274
- };
342
+ });
275
343
 
276
344
  // Same target from two languages — both edges are kept (different labels)
277
- const edges = adapter.extractEdges(concept as any);
345
+ const edges = adapter.extractEdges(concept);
278
346
  expect(edges.length).toBe(2);
279
347
  });
280
348
 
281
349
  it('extracts inline IEV cross-references from gl:references', () => {
282
- const concept = {
350
+ const concept = conceptFromJson({
283
351
  '@id': 'https://glossarist.org/test/concept/112-01-01',
352
+ '@type': 'gl:Concept',
284
353
  'gl:localizedConcept': {
285
354
  eng: {
286
355
  'gl:references': [
@@ -289,9 +358,9 @@ describe('DatasetAdapter', () => {
289
358
  ],
290
359
  },
291
360
  },
292
- };
361
+ });
293
362
 
294
- const edges = adapter.extractEdges(concept as any);
363
+ const edges = adapter.extractEdges(concept);
295
364
  expect(edges.length).toBe(2);
296
365
  expect(edges[0].target).toBe('https://glossarist.org/iev/concept/102-02-18');
297
366
  expect(edges[0].label).toBe('scalar');
@@ -300,8 +369,9 @@ describe('DatasetAdapter', () => {
300
369
  });
301
370
 
302
371
  it('extracts inline URN cross-references from gl:references', () => {
303
- const concept = {
372
+ const concept = conceptFromJson({
304
373
  '@id': 'https://glossarist.org/test/concept/3.1.1.1',
374
+ '@type': 'gl:Concept',
305
375
  'gl:localizedConcept': {
306
376
  eng: {
307
377
  'gl:references': [
@@ -309,15 +379,94 @@ describe('DatasetAdapter', () => {
309
379
  ],
310
380
  },
311
381
  },
312
- };
382
+ });
313
383
 
314
- const edges = adapter.extractEdges(concept as any);
384
+ const edges = adapter.extractEdges(concept);
315
385
  expect(edges.length).toBe(1);
316
386
  expect(edges[0].target).toBe('https://glossarist.org/isotc204/concept/3.1.1.6');
317
387
  expect(edges[0].label).toBe('entity');
318
388
  });
319
389
  });
320
390
 
391
+ describe('extractDomainEdges', () => {
392
+ it('extracts domain edges from gl:domain field per language', () => {
393
+ const concept = conceptFromJson({
394
+ '@id': 'https://glossarist.org/test/concept/3',
395
+ '@type': 'gl:Concept',
396
+ 'gl:localizedConcept': {
397
+ eng: { 'gl:domain': 'geometry' },
398
+ fra: { 'gl:domain': 'géométrie' },
399
+ },
400
+ });
401
+ const edges = adapter.extractDomainEdges(concept);
402
+ expect(edges.length).toBe(2);
403
+ expect(edges.every(e => e.type === 'domain')).toBe(true);
404
+ expect(edges.find(e => e.lang === 'eng')?.target).toContain('/domain/geometry');
405
+ expect(edges.find(e => e.lang === 'fra')?.target).toContain('/domain/gomtrie');
406
+ expect(edges.find(e => e.lang === 'eng')?.label).toBe('geometry');
407
+ expect(edges.find(e => e.lang === 'fra')?.label).toBe('géométrie');
408
+ });
409
+
410
+ it('handles same domain across languages', () => {
411
+ const concept = conceptFromJson({
412
+ '@id': 'https://glossarist.org/test/concept/1',
413
+ '@type': 'gl:Concept',
414
+ 'gl:localizedConcept': {
415
+ eng: { 'gl:domain': 'metadata' },
416
+ fra: { 'gl:domain': 'metadata' },
417
+ },
418
+ });
419
+ const edges = adapter.extractDomainEdges(concept);
420
+ expect(edges.length).toBe(2);
421
+ expect(edges[0].target).toBe(edges[1].target);
422
+ expect(edges[0].target).toContain('/domain/metadata');
423
+ });
424
+
425
+ it('skips concepts without gl:domain', () => {
426
+ const concept = conceptFromJson({
427
+ '@id': 'https://glossarist.org/test/concept/1',
428
+ '@type': 'gl:Concept',
429
+ 'gl:localizedConcept': { eng: {} },
430
+ });
431
+ const edges = adapter.extractDomainEdges(concept);
432
+ expect(edges.length).toBe(0);
433
+ });
434
+
435
+ it('handles empty localizedConcept', () => {
436
+ const concept = conceptFromJson({
437
+ '@id': 'https://glossarist.org/test/concept/1',
438
+ '@type': 'gl:Concept',
439
+ 'gl:localizedConcept': {},
440
+ });
441
+ const edges = adapter.extractDomainEdges(concept);
442
+ expect(edges.length).toBe(0);
443
+ });
444
+ });
445
+
446
+ describe('loadDomainNodes', () => {
447
+ it('loads domain nodes from domain-nodes.json', async () => {
448
+ mockFetch.mockReturnValue(mockJsonResponse({
449
+ registerId: 'test',
450
+ domainNodes: [
451
+ { uri: 'https://glossarist.org/test/domain/iso-19107', label: 'ISO 19107', registerId: 'test', conceptCount: 147 },
452
+ ],
453
+ }));
454
+ const nodes = await adapter.loadDomainNodes();
455
+ expect(nodes.length).toBe(1);
456
+ expect(nodes[0].nodeType).toBe('domain');
457
+ expect(nodes[0].status).toBe('domain');
458
+ expect(nodes[0].loaded).toBe(true);
459
+ expect(nodes[0].designations.eng).toBe('ISO 19107');
460
+ expect(mockFetch).toHaveBeenCalledWith('/data/test/domain-nodes.json');
461
+ });
462
+
463
+ it('returns empty array on fetch failure', async () => {
464
+ mockFetch.mockReturnValue(Promise.resolve({ ok: false, status: 404 } as Response));
465
+ const nodes = await adapter.loadDomainNodes();
466
+ expect(nodes).toEqual([]);
467
+ });
468
+ });
469
+
321
470
  describe('getLanguages', () => {
322
471
  it('returns languages from manifest', async () => {
323
472
  const manifest = {
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount, flushPromises } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import { createRouter, createMemoryHistory } from 'vue-router';
5
+ import DatasetView from '../views/DatasetView.vue';
6
+ import { useVocabularyStore } from '../stores/vocabulary';
7
+ import type { Manifest, ConceptSummary } from '../adapters/types';
8
+
9
+ function makeManifest(overrides: Partial<Manifest> = {}): Manifest {
10
+ return {
11
+ id: 'test',
12
+ datasetUri: 'https://glossarist.org/test/concept',
13
+ title: 'Test Dataset',
14
+ description: 'A test dataset',
15
+ owner: 'ISO',
16
+ baseUrl: '/data/test',
17
+ languages: ['eng', 'fra'],
18
+ conceptCount: 100,
19
+ conceptUrlTemplate: '/data/test/concepts/{id}.json',
20
+ indexUrl: '/data/test/index.json',
21
+ contextUrl: '/data/test/context.json',
22
+ uriBase: 'https://glossarist.org',
23
+ status: 'published',
24
+ schemaVersion: '1.0',
25
+ tags: [],
26
+ lastUpdated: '2025-01-01',
27
+ sourceRepo: 'https://example.com/repo',
28
+ chunkSize: 1000,
29
+ color: '#3366ff',
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ function makeConcepts(count: number): ConceptSummary[] {
35
+ return Array.from({ length: count }, (_, i) => ({
36
+ id: String(i + 1),
37
+ designations: { eng: `term ${i + 1}` },
38
+ eng: `term ${i + 1}`,
39
+ status: i % 10 === 0 ? 'superseded' : 'valid',
40
+ }));
41
+ }
42
+
43
+ function makeAdapter(concepts: ConceptSummary[] = []) {
44
+ const dense = concepts.filter(Boolean);
45
+ return {
46
+ registerId: 'test',
47
+ index: dense,
48
+ manifest: null,
49
+ getConceptCount: () => dense.length,
50
+ getConcepts: () => dense,
51
+ isRangeLoaded: () => true,
52
+ ensureChunksForRange: async () => {},
53
+ ensureAllChunksLoaded: async () => {},
54
+ getAdjacentConcepts: () => ({ prev: null, next: null }),
55
+ } as any;
56
+ }
57
+
58
+ async function createTestRouter() {
59
+ const router = createRouter({
60
+ history: createMemoryHistory(),
61
+ routes: [
62
+ { path: '/', name: 'home', component: { template: '<div/>' } },
63
+ { path: '/dataset/:registerId', name: 'dataset', component: { template: '<div/>' } },
64
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
65
+ { path: '/dataset/:registerId/stats', name: 'stats', component: { template: '<div/>' } },
66
+ { path: '/dataset/:registerId/about', name: 'about', component: { template: '<div/>' } },
67
+ ],
68
+ });
69
+ router.push('/dataset/test');
70
+ await router.isReady();
71
+ return router;
72
+ }
73
+
74
+ describe('DatasetView', () => {
75
+ let pinia: ReturnType<typeof createPinia>;
76
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
77
+
78
+ beforeEach(async () => {
79
+ pinia = createPinia();
80
+ setActivePinia(pinia);
81
+ router = await createTestRouter();
82
+ });
83
+
84
+ function mountDataset(concepts: ConceptSummary[] = [], manifestOverrides: Partial<Manifest> = {}) {
85
+ const store = useVocabularyStore();
86
+ const manifest = makeManifest(manifestOverrides);
87
+ store.manifests.set('test', manifest);
88
+ store.datasets.set('test', makeAdapter(concepts));
89
+ return mount(DatasetView, {
90
+ global: { plugins: [pinia, router] },
91
+ props: { registerId: 'test' },
92
+ });
93
+ }
94
+
95
+ it('renders dataset title and description', async () => {
96
+ const wrapper = mountDataset(makeConcepts(10));
97
+ await flushPromises();
98
+ expect(wrapper.text()).toContain('Test Dataset');
99
+ expect(wrapper.text()).toContain('A test dataset');
100
+ });
101
+
102
+ it('shows concept count badge', async () => {
103
+ const wrapper = mountDataset(makeConcepts(100));
104
+ await flushPromises();
105
+ expect(wrapper.text()).toContain('100 concepts');
106
+ });
107
+
108
+ it('shows language count badge', async () => {
109
+ const wrapper = mountDataset(makeConcepts(10));
110
+ await flushPromises();
111
+ expect(wrapper.text()).toContain('2 languages');
112
+ });
113
+
114
+ it('shows owner badge', async () => {
115
+ const wrapper = mountDataset(makeConcepts(10));
116
+ await flushPromises();
117
+ expect(wrapper.text()).toContain('ISO');
118
+ });
119
+
120
+ it('shows concept cards for loaded concepts', async () => {
121
+ const wrapper = mountDataset(makeConcepts(3));
122
+ await flushPromises();
123
+ expect(wrapper.text()).toContain('term 1');
124
+ expect(wrapper.text()).toContain('term 2');
125
+ expect(wrapper.text()).toContain('term 3');
126
+ });
127
+
128
+ it('paginates at 50 concepts per page', async () => {
129
+ const wrapper = mountDataset(makeConcepts(120));
130
+ await flushPromises();
131
+ // Should show 50 on first page
132
+ expect(wrapper.text()).toContain('term 1');
133
+ expect(wrapper.text()).not.toContain('term 51');
134
+ // Should show pagination
135
+ expect(wrapper.text()).toContain('Prev');
136
+ expect(wrapper.text()).toContain('Next');
137
+ });
138
+
139
+ it('shows correct page count in pagination', async () => {
140
+ const wrapper = mountDataset(makeConcepts(120));
141
+ await flushPromises();
142
+ // 120 / 50 = 3 pages (ceil)
143
+ const pageButtons = wrapper.findAll('button').filter(b => /^\d+$/.test(b.text().trim()));
144
+ const pageNumbers = pageButtons.map(b => parseInt(b.text().trim()));
145
+ expect(pageNumbers).toContain(1);
146
+ expect(pageNumbers).toContain(3);
147
+ });
148
+
149
+ it('filters concepts by term', async () => {
150
+ const concepts: ConceptSummary[] = [
151
+ { id: '1', designations: { eng: 'road network' }, eng: 'road network', status: 'valid' },
152
+ { id: '2', designations: { eng: 'bridge design' }, eng: 'bridge design', status: 'valid' },
153
+ { id: '3', designations: { eng: 'road user' }, eng: 'road user', status: 'valid' },
154
+ ];
155
+ const wrapper = mountDataset(concepts);
156
+ await flushPromises();
157
+ const input = wrapper.find('input[aria-label="Filter concepts"]');
158
+ await input.setValue('road');
159
+ await flushPromises();
160
+ expect(wrapper.text()).toContain('road network');
161
+ expect(wrapper.text()).toContain('road user');
162
+ expect(wrapper.text()).not.toContain('bridge design');
163
+ });
164
+
165
+ it('filters concepts by ID', async () => {
166
+ const concepts: ConceptSummary[] = [
167
+ { id: '3.1.1.1', designations: { eng: 'term one' }, eng: 'term one', status: 'valid' },
168
+ { id: '3.1.1.2', designations: { eng: 'term two' }, eng: 'term two', status: 'valid' },
169
+ ];
170
+ const wrapper = mountDataset(concepts);
171
+ await flushPromises();
172
+ const input = wrapper.find('input[aria-label="Filter concepts"]');
173
+ await input.setValue('3.1.1.1');
174
+ await flushPromises();
175
+ expect(wrapper.text()).toContain('term one');
176
+ expect(wrapper.text()).not.toContain('term two');
177
+ });
178
+
179
+ it('shows empty state when filter matches nothing', async () => {
180
+ const wrapper = mountDataset(makeConcepts(5));
181
+ await flushPromises();
182
+ const input = wrapper.find('input[aria-label="Filter concepts"]');
183
+ await input.setValue('zzzznonexistent');
184
+ await flushPromises();
185
+ expect(wrapper.text()).toContain('No concepts match your filter');
186
+ });
187
+
188
+ it('shows clear filter button on empty state', async () => {
189
+ const wrapper = mountDataset(makeConcepts(5));
190
+ await flushPromises();
191
+ const input = wrapper.find('input[aria-label="Filter concepts"]');
192
+ await input.setValue('zzzznonexistent');
193
+ await flushPromises();
194
+ expect(wrapper.text()).toContain('Clear filter');
195
+ });
196
+
197
+ it('shows links to stats and about pages', async () => {
198
+ const wrapper = mountDataset(makeConcepts(5));
199
+ await flushPromises();
200
+ expect(wrapper.text()).toContain('Statistics');
201
+ expect(wrapper.text()).toContain('About');
202
+ });
203
+
204
+ it('shows bulk downloads when manifest has bulkFormats', async () => {
205
+ const wrapper = mountDataset(makeConcepts(5), {
206
+ bulkFormats: [
207
+ { file: 'all.ttl', format: 'turtle', size: 1024 },
208
+ { file: 'all.jsonld', format: 'jsonld', size: 2048 },
209
+ ],
210
+ });
211
+ await flushPromises();
212
+ expect(wrapper.text()).toContain('Download');
213
+ expect(wrapper.text()).toContain('1.0 KB');
214
+ });
215
+
216
+ it('shows 0 of N concepts in filter count', async () => {
217
+ const wrapper = mountDataset(makeConcepts(10));
218
+ await flushPromises();
219
+ const input = wrapper.find('input[aria-label="Filter concepts"]');
220
+ await input.setValue('term 5');
221
+ await flushPromises();
222
+ expect(wrapper.text()).toContain('of 10 concepts');
223
+ });
224
+
225
+ it('disables Prev on first page', async () => {
226
+ const wrapper = mountDataset(makeConcepts(120));
227
+ await flushPromises();
228
+ const prevBtn = wrapper.findAll('button').find(b => b.text().includes('Prev'));
229
+ expect(prevBtn).toBeDefined();
230
+ expect(prevBtn!.attributes('disabled')).toBeDefined();
231
+ });
232
+ });