@glossarist/concept-browser 0.3.4 → 0.3.7

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/about-view.test.ts +98 -0
  3. package/src/__tests__/app-footer.test.ts +38 -0
  4. package/src/__tests__/app-header.test.ts +130 -0
  5. package/src/__tests__/app-sidebar.test.ts +159 -0
  6. package/src/__tests__/asciidoc-lite.test.ts +1 -1
  7. package/src/__tests__/concept-card.test.ts +115 -0
  8. package/src/__tests__/concept-detail-interaction.test.ts +251 -0
  9. package/src/__tests__/concept-timeline.test.ts +175 -0
  10. package/src/__tests__/concept-view.test.ts +75 -0
  11. package/src/__tests__/contributors-view.test.ts +103 -0
  12. package/src/__tests__/dataset-view.test.ts +231 -0
  13. package/src/__tests__/format-downloads.test.ts +98 -0
  14. package/src/__tests__/graph-view.test.ts +69 -0
  15. package/src/__tests__/home-interaction.test.ts +157 -0
  16. package/src/__tests__/language-detail.test.ts +146 -0
  17. package/src/__tests__/nav-icon.test.ts +48 -0
  18. package/src/__tests__/news-view.test.ts +87 -0
  19. package/src/__tests__/page-view.test.ts +83 -0
  20. package/src/__tests__/resolve-view.test.ts +77 -0
  21. package/src/__tests__/router.test.ts +65 -0
  22. package/src/__tests__/search-bar.test.ts +219 -0
  23. package/src/__tests__/search-view.test.ts +41 -0
  24. package/src/__tests__/stats-view.test.ts +77 -0
  25. package/src/__tests__/test-helpers.ts +168 -0
  26. package/src/__tests__/ui-store.test.ts +100 -0
  27. package/src/__tests__/v-math.test.ts +8 -7
  28. package/src/adapters/DatasetAdapter.ts +17 -15
  29. package/src/adapters/types.ts +1 -1
  30. package/src/components/ConceptDetail.vue +16 -54
  31. package/src/components/ConceptTimeline.vue +1 -8
  32. package/src/components/LanguageDetail.vue +2 -25
  33. package/src/composables/use-render-options.ts +1 -4
  34. package/src/router/index.ts +1 -1
  35. package/src/stores/vocabulary.ts +7 -7
  36. package/src/utils/asciidoc-lite.ts +17 -19
  37. package/src/utils/concept-helpers.ts +34 -0
  38. package/src/utils/escape.ts +7 -0
  39. package/src/utils/markdown-lite.ts +1 -3
  40. package/src/utils/math.ts +2 -11
  41. package/src/utils/plurimath.ts +2 -7
  42. package/src/views/ConceptView.vue +22 -1
  43. package/src/views/DatasetView.vue +7 -2
@@ -0,0 +1,251 @@
1
+ import { describe, it, expect, beforeEach, vi } 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 ConceptDetail from '../components/ConceptDetail.vue';
6
+ import { useVocabularyStore } from '../stores/vocabulary';
7
+ import type { Manifest, ConceptDocument, LocalizedConcept } from '../adapters/types';
8
+ // Prevent the 2.7MB Opal runtime from loading in tests
9
+ vi.mock('../utils/plurimath', () => ({
10
+ loadPlurimath: () => new Promise(() => {}),
11
+ mathToHtml: () => '<code class="math-fallback">x</code>',
12
+ renderToMathML: () => null,
13
+ }));
14
+
15
+ import { vMath } from '../directives/v-math';
16
+
17
+ function makeManifest(): Manifest {
18
+ return {
19
+ id: 'test',
20
+ datasetUri: 'https://glossarist.org/test/concept',
21
+ title: 'Test Dataset',
22
+ description: 'A test dataset',
23
+ owner: 'ISO',
24
+ baseUrl: '/data/test',
25
+ languages: ['eng', 'fra'],
26
+ conceptCount: 1,
27
+ conceptUrlTemplate: '/data/test/concepts/{id}.json',
28
+ indexUrl: '/data/test/index.json',
29
+ contextUrl: '/data/test/context.json',
30
+ uriBase: 'https://glossarist.org',
31
+ status: 'published',
32
+ schemaVersion: '1.0',
33
+ tags: [],
34
+ lastUpdated: '2025-01-01',
35
+ sourceRepo: 'https://example.com/repo',
36
+ chunkSize: 1000,
37
+ languageOrder: ['eng', 'fra'],
38
+ color: '#3366ff',
39
+ };
40
+ }
41
+
42
+ function makeConcept(): ConceptDocument {
43
+ return {
44
+ '@context': 'https://glossarist.org/context',
45
+ '@id': 'https://glossarist.org/test/concept/1',
46
+ '@type': 'gl:Concept',
47
+ 'gl:identifier': '1',
48
+ 'gl:localizedConcept': {
49
+ eng: {
50
+ '@id': 'https://glossarist.org/test/concept/1/eng',
51
+ '@type': 'gl:LocalizedConcept',
52
+ 'gl:languageCode': 'eng',
53
+ 'gl:entryStatus': 'valid',
54
+ 'gl:designation': [
55
+ { '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'test term' },
56
+ { '@type': 'gl:Symbol', 'gl:normativeStatus': 'admitted', 'gl:term': 'stem:[x]' },
57
+ ],
58
+ 'gl:definition': [
59
+ { '@type': 'gl:DetailedDefinition', 'gl:content': 'a definition with *italic* text' },
60
+ ],
61
+ 'gl:notes': [
62
+ { '@type': 'gl:DetailedDefinition', 'gl:content': 'a note' },
63
+ ],
64
+ 'gl:examples': [
65
+ { '@type': 'gl:DetailedDefinition', 'gl:content': 'an example' },
66
+ ],
67
+ 'gl:source': [{ '@type': 'gl:Source', 'gl:sourceType': 'authoritative' }],
68
+ },
69
+ fra: {
70
+ '@id': 'https://glossarist.org/test/concept/1/fra',
71
+ '@type': 'gl:LocalizedConcept',
72
+ 'gl:languageCode': 'fra',
73
+ 'gl:entryStatus': 'valid',
74
+ 'gl:designation': [
75
+ { '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'terme de test' },
76
+ ],
77
+ 'gl:definition': [
78
+ { '@type': 'gl:DetailedDefinition', 'gl:content': 'une définition' },
79
+ ],
80
+ },
81
+ },
82
+ };
83
+ }
84
+
85
+ async function createTestRouter() {
86
+ return createRouter({
87
+ history: createMemoryHistory(),
88
+ routes: [
89
+ { path: '/', name: 'home', component: { template: '<div/>' } },
90
+ { path: '/dataset/:registerId', name: 'dataset', component: { template: '<div/>' } },
91
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
92
+ ],
93
+ });
94
+ }
95
+
96
+ describe('ConceptDetail interactions', () => {
97
+ let pinia: ReturnType<typeof createPinia>;
98
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
99
+ let store: ReturnType<typeof useVocabularyStore>;
100
+
101
+ beforeEach(async () => {
102
+ pinia = createPinia();
103
+ setActivePinia(pinia);
104
+ router = await createTestRouter();
105
+ router.push('/');
106
+ await router.isReady();
107
+ store = useVocabularyStore();
108
+ store.manifests.set('test', makeManifest());
109
+ store.datasets.set('test', { index: [], getConceptCount: () => 0, getConcepts: () => [], getConceptPosition: () => -1, getIndexEntry: () => undefined } as any);
110
+ });
111
+
112
+ function mountDetail(concept = makeConcept()) {
113
+ return mount(ConceptDetail, {
114
+ global: {
115
+ plugins: [pinia, router],
116
+ directives: { math: vMath },
117
+ },
118
+ props: {
119
+ concept,
120
+ manifest: makeManifest(),
121
+ edges: [],
122
+ registerId: 'test',
123
+ adjacent: { prev: null, next: null },
124
+ },
125
+ });
126
+ }
127
+
128
+ it('renders the primary term in the header', () => {
129
+ const wrapper = mountDetail();
130
+ expect(wrapper.find('h1').html()).toContain('test term');
131
+ });
132
+
133
+ it('renders concept ID badge', () => {
134
+ const wrapper = mountDetail();
135
+ expect(wrapper.text()).toContain('1');
136
+ });
137
+
138
+ it('renders language sections for eng and fra', () => {
139
+ const wrapper = mountDetail();
140
+ expect(wrapper.text()).toContain('English');
141
+ expect(wrapper.text()).toContain('French');
142
+ });
143
+
144
+ it('renders italic text in definition', () => {
145
+ const wrapper = mountDetail();
146
+ expect(wrapper.html()).toContain('<em>italic</em>');
147
+ });
148
+
149
+ it('renders stem: notation as math-pending placeholder', () => {
150
+ const wrapper = mountDetail();
151
+ expect(wrapper.html()).toContain('math-pending');
152
+ expect(wrapper.html()).toContain('data-expr="x"');
153
+ });
154
+
155
+ it('renders notes section', () => {
156
+ const wrapper = mountDetail();
157
+ expect(wrapper.text()).toContain('Note 1');
158
+ expect(wrapper.text()).toContain('a note');
159
+ });
160
+
161
+ it('renders examples section', () => {
162
+ const wrapper = mountDetail();
163
+ expect(wrapper.text()).toContain('Example 1');
164
+ expect(wrapper.text()).toContain('an example');
165
+ });
166
+
167
+ it('renders designation types as badges', () => {
168
+ const wrapper = mountDetail();
169
+ expect(wrapper.text()).toContain('Symbol');
170
+ });
171
+
172
+ it('collapses non-eng languages when 6+ languages present', async () => {
173
+ const concept = makeConcept();
174
+ for (const lang of ['deu', 'spa', 'kor', 'jpn']) {
175
+ concept['gl:localizedConcept']![lang] = {
176
+ '@id': `https://glossarist.org/test/concept/1/${lang}`,
177
+ '@type': 'gl:LocalizedConcept',
178
+ 'gl:languageCode': lang,
179
+ 'gl:designation': [
180
+ { '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': `term-${lang}` },
181
+ ],
182
+ 'gl:definition': [
183
+ { '@type': 'gl:DetailedDefinition', 'gl:content': `def-${lang}` },
184
+ ],
185
+ };
186
+ }
187
+ const wrapper = mountDetail(concept);
188
+ await flushPromises();
189
+ expect(wrapper.text()).toContain('6 languages');
190
+ });
191
+
192
+ it('toggles language section on click', async () => {
193
+ const wrapper = mountDetail();
194
+ const buttons = wrapper.findAll('button');
195
+ const fraButton = buttons.find(b => b.text().includes('French'));
196
+ expect(fraButton).toBeDefined();
197
+
198
+ await fraButton!.trigger('click');
199
+ await fraButton!.trigger('click');
200
+ });
201
+
202
+ it('switches between definition and history tabs', async () => {
203
+ const wrapper = mountDetail();
204
+ expect(wrapper.text()).toContain('a definition with');
205
+
206
+ const tabs = wrapper.findAll('button[role="tab"]');
207
+ const historyTab = tabs.find(t => t.text().includes('History'));
208
+ expect(historyTab).toBeDefined();
209
+ await historyTab!.trigger('click');
210
+ await flushPromises();
211
+
212
+ expect(wrapper.text()).not.toContain('a definition with');
213
+ });
214
+
215
+ it('renders cross-reference link and navigates on click', async () => {
216
+ const concept = makeConcept();
217
+ const eng = concept['gl:localizedConcept']!.eng!;
218
+ eng['gl:definition'] = [
219
+ { '@type': 'gl:DetailedDefinition', 'gl:content': 'see {{urn:iso:std:iso:14812:3.1.1.1,entity}} here' },
220
+ ];
221
+
222
+ // Register the URI pattern via factory so it resolves as internal
223
+ const { getFactory } = await import('../adapters/factory');
224
+ const factory = getFactory();
225
+ factory.router.registerDataset('test', '/data/test', {
226
+ ...makeManifest(),
227
+ uriBase: 'https://glossarist.org',
228
+ });
229
+ factory.resolver.registerDataset('test', ['https://glossarist.org/test/concept/*']);
230
+
231
+ const wrapper = mountDetail(concept);
232
+ await flushPromises();
233
+
234
+ const xref = wrapper.find('.xref-link');
235
+ if (xref.exists()) {
236
+ await xref.trigger('click');
237
+ await flushPromises();
238
+ expect(router.currentRoute.value.name).toBe('concept');
239
+ }
240
+ });
241
+
242
+ it('renders entry status badge', () => {
243
+ const wrapper = mountDetail();
244
+ expect(wrapper.text()).toContain('valid');
245
+ });
246
+
247
+ it('renders the language quick-jump sidebar with all languages', () => {
248
+ const wrapper = mountDetail();
249
+ expect(wrapper.text()).toContain('Languages (2)');
250
+ });
251
+ });
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount, flushPromises } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import ConceptTimeline from '../components/ConceptTimeline.vue';
5
+ import type { LocalizedConcept } from '../adapters/types';
6
+
7
+ function makeLocalizedConcept(overrides: Partial<LocalizedConcept> = {}): LocalizedConcept {
8
+ return {
9
+ '@id': 'https://glossarist.org/test/concept/1/eng',
10
+ '@type': 'gl:LocalizedConcept',
11
+ 'gl:languageCode': 'eng',
12
+ 'gl:entryStatus': 'valid',
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ function mountTimeline(lcs: Record<string, LocalizedConcept>, activeLang = 'eng', languageOrder?: string[]) {
18
+ return mount(ConceptTimeline, {
19
+ props: {
20
+ localizedConcepts: lcs,
21
+ activeLang,
22
+ languageOrder,
23
+ },
24
+ });
25
+ }
26
+
27
+ describe('ConceptTimeline', () => {
28
+ it('shows empty state when no history data', () => {
29
+ const lc = makeLocalizedConcept({ 'gl:entryStatus': undefined });
30
+ const wrapper = mountTimeline({ eng: lc });
31
+ expect(wrapper.text()).toContain('No history data available');
32
+ });
33
+
34
+ it('renders review date as timeline entry', () => {
35
+ const lc = makeLocalizedConcept({
36
+ 'gl:reviewDate': '2023-05-15',
37
+ });
38
+ const wrapper = mountTimeline({ eng: lc });
39
+ expect(wrapper.text()).toContain('Review initiated');
40
+ expect(wrapper.text()).toContain('2023');
41
+ });
42
+
43
+ it('renders review decision event', () => {
44
+ const lc = makeLocalizedConcept({
45
+ 'gl:reviewDecisionEvent': 'Accepted',
46
+ 'gl:reviewDate': '2023-05-15',
47
+ 'gl:reviewDecisionDate': '2023-06-01',
48
+ });
49
+ const wrapper = mountTimeline({ eng: lc });
50
+ // The review event banner
51
+ expect(wrapper.text()).toContain('Accepted');
52
+ });
53
+
54
+ it('renders gl:dates entries', () => {
55
+ const lc = makeLocalizedConcept({
56
+ 'gl:dates': [
57
+ { 'gl:dateType': 'accepted', 'gl:date': '2020-01-01' },
58
+ { 'gl:dateType': 'amended', 'gl:date': '2022-06-15' },
59
+ ],
60
+ });
61
+ const wrapper = mountTimeline({ eng: lc });
62
+ expect(wrapper.text()).toContain('Concept accepted');
63
+ expect(wrapper.text()).toContain('Definition amended');
64
+ expect(wrapper.text()).toContain('2020');
65
+ expect(wrapper.text()).toContain('2022');
66
+ });
67
+
68
+ it('sorts entries by date ascending', () => {
69
+ const lc = makeLocalizedConcept({
70
+ 'gl:dates': [
71
+ { 'gl:dateType': 'amended', 'gl:date': '2022-06-15' },
72
+ { 'gl:dateType': 'accepted', 'gl:date': '2020-01-01' },
73
+ ],
74
+ });
75
+ const wrapper = mountTimeline({ eng: lc });
76
+ const texts = wrapper.text();
77
+ // "accepted" entry should appear before "amended" in the rendered output
78
+ const acceptedIdx = texts.indexOf('Concept accepted');
79
+ const amendedIdx = texts.indexOf('Definition amended');
80
+ expect(acceptedIdx).toBeLessThan(amendedIdx);
81
+ });
82
+
83
+ it('shows language selector when multiple languages have history', () => {
84
+ const eng = makeLocalizedConcept({ 'gl:reviewDate': '2023-01-01' });
85
+ const fra = makeLocalizedConcept({
86
+ '@id': 'https://glossarist.org/test/concept/1/fra',
87
+ 'gl:languageCode': 'fra',
88
+ 'gl:reviewDate': '2023-02-01',
89
+ });
90
+ const wrapper = mountTimeline({ eng, fra }, 'eng', ['eng', 'fra']);
91
+ expect(wrapper.text()).toContain('French');
92
+ });
93
+
94
+ it('does not show language selector for single-language history', () => {
95
+ const eng = makeLocalizedConcept({ 'gl:reviewDate': '2023-01-01' });
96
+ const wrapper = mountTimeline({ eng });
97
+ // Should not have multiple language buttons
98
+ const buttons = wrapper.findAll('button');
99
+ const langButtons = buttons.filter(b => b.text().includes('French'));
100
+ expect(langButtons.length).toBe(0);
101
+ });
102
+
103
+ it('emits update:activeLang on language button click', async () => {
104
+ const eng = makeLocalizedConcept({ 'gl:reviewDate': '2023-01-01' });
105
+ const fra = makeLocalizedConcept({
106
+ '@id': 'https://glossarist.org/test/concept/1/fra',
107
+ 'gl:languageCode': 'fra',
108
+ 'gl:reviewDate': '2023-02-01',
109
+ });
110
+ const wrapper = mountTimeline({ eng, fra }, 'eng', ['eng', 'fra']);
111
+ const fraBtn = wrapper.findAll('button').find(b => b.text().includes('French'));
112
+ expect(fraBtn).toBeDefined();
113
+ await fraBtn!.trigger('click');
114
+ expect(wrapper.emitted('update:activeLang')?.[0]).toEqual(['fra']);
115
+ });
116
+
117
+ it('shows review metadata when present', () => {
118
+ const lc = makeLocalizedConcept({
119
+ 'gl:reviewDate': '2023-01-01',
120
+ 'gl:reviewStatus': 'final',
121
+ 'gl:reviewDecision': 'accepted',
122
+ 'gl:entryStatus': 'valid',
123
+ 'gl:release': 3,
124
+ });
125
+ const wrapper = mountTimeline({ eng: lc });
126
+ expect(wrapper.text()).toContain('Review Details');
127
+ expect(wrapper.text()).toContain('final');
128
+ expect(wrapper.text()).toContain('accepted');
129
+ expect(wrapper.text()).toContain('valid');
130
+ expect(wrapper.text()).toContain('3');
131
+ });
132
+
133
+ it('groups by year when more than 3 entries', () => {
134
+ const lc = makeLocalizedConcept({
135
+ 'gl:dates': [
136
+ { 'gl:dateType': 'accepted', 'gl:date': '2019-03-01' },
137
+ { 'gl:dateType': 'amended', 'gl:date': '2020-06-15' },
138
+ { 'gl:dateType': 'amended', 'gl:date': '2021-09-20' },
139
+ { 'gl:dateType': 'published', 'gl:date': '2023-01-10' },
140
+ ],
141
+ });
142
+ const wrapper = mountTimeline({ eng: lc });
143
+ // Year markers should be rendered
144
+ expect(wrapper.text()).toContain('2019');
145
+ expect(wrapper.text()).toContain('2020');
146
+ expect(wrapper.text()).toContain('2021');
147
+ expect(wrapper.text()).toContain('2023');
148
+ });
149
+
150
+ it('uses simple layout for 3 or fewer entries', () => {
151
+ const lc = makeLocalizedConcept({
152
+ 'gl:dates': [
153
+ { 'gl:dateType': 'accepted', 'gl:date': '2020-01-01' },
154
+ { 'gl:dateType': 'amended', 'gl:date': '2022-06-15' },
155
+ ],
156
+ });
157
+ const wrapper = mountTimeline({ eng: lc });
158
+ // Should have entries but no year grouping markers
159
+ expect(wrapper.text()).toContain('Concept accepted');
160
+ expect(wrapper.text()).toContain('Definition amended');
161
+ });
162
+
163
+ it('deduplicates review date if it matches a gl:date entry', () => {
164
+ const lc = makeLocalizedConcept({
165
+ 'gl:dates': [
166
+ { 'gl:dateType': 'review', 'gl:date': '2023-05-15' },
167
+ ],
168
+ 'gl:reviewDate': '2023-05-15',
169
+ });
170
+ const wrapper = mountTimeline({ eng: lc });
171
+ // Should only have one entry for that date, not two "review" entries
172
+ const reviewCount = wrapper.text().split('Review initiated').length - 1;
173
+ expect(reviewCount).toBeLessThanOrEqual(1);
174
+ });
175
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount, flushPromises } from '@vue/test-utils';
3
+ import ConceptView from '../views/ConceptView.vue';
4
+ import { useVocabularyStore } from '../stores/vocabulary';
5
+ import { createTestRouter, setupPinia, makeManifest, makeAdapterStub } from './test-helpers';
6
+
7
+ describe('ConceptView', () => {
8
+ let pinia: ReturnType<typeof setupPinia>;
9
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
10
+
11
+ beforeEach(async () => {
12
+ pinia = setupPinia();
13
+ router = await createTestRouter('dataset', '/');
14
+ const store = useVocabularyStore();
15
+ store.manifests.set('test', makeManifest());
16
+ store.datasets.set('test', makeAdapterStub());
17
+ });
18
+
19
+ function mountConceptView(registerId = 'test', conceptId = '1') {
20
+ return mount(ConceptView, {
21
+ global: {
22
+ plugins: [pinia, router],
23
+ stubs: { ConceptDetail: true },
24
+ },
25
+ props: { registerId, conceptId },
26
+ });
27
+ }
28
+
29
+ it('shows loading skeleton initially', () => {
30
+ const wrapper = mountConceptView();
31
+ expect(wrapper.find('.skeleton').exists()).toBe(true);
32
+ });
33
+
34
+ it('shows error when fetchConcept returns null', async () => {
35
+ const wrapper = mountConceptView();
36
+ await flushPromises();
37
+ expect(wrapper.text()).toContain('Failed to load concept');
38
+ });
39
+
40
+ it('shows retry button on error', async () => {
41
+ const wrapper = mountConceptView();
42
+ await flushPromises();
43
+ const retryBtn = wrapper.findAll('button').find(b => b.text() === 'Retry');
44
+ expect(retryBtn).toBeDefined();
45
+ });
46
+
47
+ it('shows back to dataset link on error', async () => {
48
+ const wrapper = mountConceptView();
49
+ await flushPromises();
50
+ const link = wrapper.findAll('a').find(a => a.text().includes('Back to dataset'));
51
+ expect(link).toBeDefined();
52
+ });
53
+
54
+ it('renders ConceptDetail when concept loads', async () => {
55
+ const concept = {
56
+ '@id': 'https://glossarist.org/test/concept/1',
57
+ '@type': 'gl:Concept',
58
+ };
59
+ const store = useVocabularyStore();
60
+ store.datasets.set('test', makeAdapterStub({ fetchConcept: () => Promise.resolve(concept) }));
61
+
62
+ const wrapper = mountConceptView();
63
+ await flushPromises();
64
+ expect(wrapper.findComponent({ name: 'ConceptDetail' }).exists()).toBe(true);
65
+ });
66
+
67
+ it('shows error when dataset not found', async () => {
68
+ const store = useVocabularyStore();
69
+ store.datasets.delete('test');
70
+ store.manifests.delete('test');
71
+ const wrapper = mountConceptView();
72
+ await flushPromises();
73
+ expect(wrapper.text()).toContain('Failed to load concept');
74
+ });
75
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeEach, vi } 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 ContributorsView from '../views/ContributorsView.vue';
6
+ import { useSiteConfig } from '../config/use-site-config';
7
+
8
+ // Mock useSiteConfig so we can inject contributor data
9
+ vi.mock('../config/use-site-config', () => {
10
+ let _config: any = {};
11
+ return {
12
+ useSiteConfig: () => ({ config: { value: _config } }),
13
+ __setConfig: (c: any) => { _config = c; },
14
+ };
15
+ });
16
+
17
+ async function createTestRouter() {
18
+ const router = createRouter({
19
+ history: createMemoryHistory(),
20
+ routes: [
21
+ { path: '/', name: 'home', component: { template: '<div/>' } },
22
+ { path: '/contributors', name: 'contributors', component: { template: '<div/>' } },
23
+ ],
24
+ });
25
+ router.push('/contributors');
26
+ await router.isReady();
27
+ return router;
28
+ }
29
+
30
+ describe('ContributorsView', () => {
31
+ let pinia: ReturnType<typeof createPinia>;
32
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
33
+
34
+ beforeEach(async () => {
35
+ pinia = createPinia();
36
+ setActivePinia(pinia);
37
+ router = await createTestRouter();
38
+ });
39
+
40
+ function mountContributors() {
41
+ return mount(ContributorsView, {
42
+ global: { plugins: [pinia, router] },
43
+ });
44
+ }
45
+
46
+ it('renders breadcrumb navigation', () => {
47
+ const wrapper = mountContributors();
48
+ expect(wrapper.text()).toContain('Home');
49
+ expect(wrapper.text()).toContain('Contributors');
50
+ });
51
+
52
+ it('renders Contributors heading', () => {
53
+ const wrapper = mountContributors();
54
+ expect(wrapper.text()).toContain('Contributors');
55
+ });
56
+
57
+ it('shows empty state when no contributors', () => {
58
+ const wrapper = mountContributors();
59
+ expect(wrapper.text()).toContain('No contributor information available');
60
+ });
61
+
62
+ it('shows contributors table when data present', async () => {
63
+ const { __setConfig } = await import('../config/use-site-config') as any;
64
+ __setConfig({
65
+ contributors: [
66
+ { name: 'Jane Doe', role: 'Editor', organization: 'ISO', email: 'jane@example.com', url: 'https://example.com' },
67
+ { name: 'John Smith', role: 'Author', organization: 'IEC' },
68
+ ],
69
+ });
70
+ const wrapper = mountContributors();
71
+ expect(wrapper.text()).toContain('Jane Doe');
72
+ expect(wrapper.text()).toContain('John Smith');
73
+ expect(wrapper.text()).toContain('Editor');
74
+ expect(wrapper.text()).toContain('ISO');
75
+ expect(wrapper.text()).toContain('jane@example.com');
76
+ expect(wrapper.text()).toContain('Author');
77
+ expect(wrapper.text()).toContain('IEC');
78
+ });
79
+
80
+ it('links contributor name when URL present', async () => {
81
+ const { __setConfig } = await import('../config/use-site-config') as any;
82
+ __setConfig({
83
+ contributors: [
84
+ { name: 'Jane Doe', url: 'https://example.com' },
85
+ ],
86
+ });
87
+ const wrapper = mountContributors();
88
+ const link = wrapper.findAll('a').find(a => a.text() === 'Jane Doe');
89
+ expect(link).toBeDefined();
90
+ expect(link!.attributes('href')).toBe('https://example.com');
91
+ });
92
+
93
+ it('shows dash for missing role/org', async () => {
94
+ const { __setConfig } = await import('../config/use-site-config') as any;
95
+ __setConfig({
96
+ contributors: [{ name: 'Anonymous' }],
97
+ });
98
+ const wrapper = mountContributors();
99
+ const cells = wrapper.findAll('td');
100
+ const dashCount = cells.filter(c => c.text() === '—');
101
+ expect(dashCount.length).toBeGreaterThanOrEqual(2);
102
+ });
103
+ });