@glossarist/concept-browser 0.3.3 → 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.
- package/package.json +1 -1
- package/src/__tests__/about-view.test.ts +98 -0
- package/src/__tests__/app-footer.test.ts +38 -0
- package/src/__tests__/app-header.test.ts +130 -0
- package/src/__tests__/app-sidebar.test.ts +159 -0
- package/src/__tests__/asciidoc-lite.test.ts +92 -0
- package/src/__tests__/concept-card.test.ts +115 -0
- package/src/__tests__/concept-detail-interaction.test.ts +251 -0
- package/src/__tests__/concept-timeline.test.ts +175 -0
- package/src/__tests__/concept-view.test.ts +75 -0
- package/src/__tests__/contributors-view.test.ts +103 -0
- package/src/__tests__/dataset-view.test.ts +231 -0
- package/src/__tests__/format-downloads.test.ts +98 -0
- package/src/__tests__/graph-view.test.ts +69 -0
- package/src/__tests__/home-interaction.test.ts +157 -0
- package/src/__tests__/language-detail.test.ts +146 -0
- package/src/__tests__/markdown-lite.test.ts +88 -0
- package/src/__tests__/nav-icon.test.ts +48 -0
- package/src/__tests__/news-view.test.ts +87 -0
- package/src/__tests__/page-view.test.ts +83 -0
- package/src/__tests__/plurimath.test.ts +71 -0
- package/src/__tests__/resolve-view.test.ts +77 -0
- package/src/__tests__/router.test.ts +65 -0
- package/src/__tests__/search-bar.test.ts +219 -0
- package/src/__tests__/search-view.test.ts +41 -0
- package/src/__tests__/stats-view.test.ts +77 -0
- package/src/__tests__/test-helpers.ts +168 -0
- package/src/__tests__/ui-store.test.ts +100 -0
- package/src/__tests__/v-math.test.ts +79 -0
- package/src/adapters/DatasetAdapter.ts +17 -15
- package/src/adapters/types.ts +1 -1
- package/src/components/ConceptDetail.vue +16 -54
- package/src/components/ConceptTimeline.vue +1 -8
- package/src/components/LanguageDetail.vue +2 -25
- package/src/composables/use-render-options.ts +1 -4
- package/src/router/index.ts +1 -1
- package/src/stores/vocabulary.ts +7 -7
- package/src/utils/asciidoc-lite.ts +17 -19
- package/src/utils/concept-helpers.ts +34 -0
- package/src/utils/escape.ts +7 -0
- package/src/utils/markdown-lite.ts +1 -3
- package/src/utils/math.ts +2 -11
- package/src/utils/plurimath.ts +2 -7
- package/src/views/ConceptView.vue +22 -1
- package/src/views/DatasetView.vue +7 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mount, flushPromises } from '@vue/test-utils';
|
|
3
|
+
import LanguageDetail from '../components/LanguageDetail.vue';
|
|
4
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
|
+
import type { LocalizedConcept } from '../adapters/types';
|
|
6
|
+
import { createTestRouter, setupPinia, makeManifest, makeLocalizedConcept, makeAdapterStub } from './test-helpers';
|
|
7
|
+
|
|
8
|
+
describe('LanguageDetail', () => {
|
|
9
|
+
let pinia: ReturnType<typeof setupPinia>;
|
|
10
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
pinia = setupPinia();
|
|
14
|
+
router = await createTestRouter('dataset', '/');
|
|
15
|
+
const store = useVocabularyStore();
|
|
16
|
+
store.manifests.set('test', makeManifest({ languages: ['eng', 'fra'] }));
|
|
17
|
+
store.datasets.set('test', makeAdapterStub());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function mountDetail(lcs: Record<string, LocalizedConcept>, activeLang = 'eng') {
|
|
21
|
+
return mount(LanguageDetail, {
|
|
22
|
+
global: { plugins: [pinia, router], directives: { math: () => {} } },
|
|
23
|
+
props: { localizedConcepts: lcs, activeLang },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it('renders language selector buttons', () => {
|
|
28
|
+
const eng = makeLocalizedConcept();
|
|
29
|
+
const fra = makeLocalizedConcept({ '@id': '.../fra', 'gl:languageCode': 'fra' });
|
|
30
|
+
const wrapper = mountDetail({ eng, fra });
|
|
31
|
+
expect(wrapper.text()).toContain('English');
|
|
32
|
+
expect(wrapper.text()).toContain('French');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('highlights active language button', () => {
|
|
36
|
+
const eng = makeLocalizedConcept();
|
|
37
|
+
const fra = makeLocalizedConcept({ '@id': '.../fra', 'gl:languageCode': 'fra' });
|
|
38
|
+
const wrapper = mountDetail({ eng, fra }, 'eng');
|
|
39
|
+
const buttons = wrapper.findAll('button').filter(b => b.text().includes('English'));
|
|
40
|
+
expect(buttons[0].classes()).toContain('bg-ink-800');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('emits update:activeLang on language click', async () => {
|
|
44
|
+
const eng = makeLocalizedConcept();
|
|
45
|
+
const fra = makeLocalizedConcept({ '@id': '.../fra', 'gl:languageCode': 'fra' });
|
|
46
|
+
const wrapper = mountDetail({ eng, fra }, 'eng');
|
|
47
|
+
const fraBtn = wrapper.findAll('button').find(b => b.text().includes('French'));
|
|
48
|
+
expect(fraBtn).toBeDefined();
|
|
49
|
+
await fraBtn!.trigger('click');
|
|
50
|
+
expect(wrapper.emitted('update:activeLang')?.[0]).toEqual(['fra']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows entry status badge', () => {
|
|
54
|
+
const eng = makeLocalizedConcept({ 'gl:entryStatus': 'valid' });
|
|
55
|
+
const wrapper = mountDetail({ eng });
|
|
56
|
+
expect(wrapper.text()).toContain('valid');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('shows designations', () => {
|
|
60
|
+
const eng = makeLocalizedConcept({
|
|
61
|
+
'gl:designation': [
|
|
62
|
+
{ '@type': 'gl:Expression', 'gl:term': 'road', 'gl:normativeStatus': 'preferred' },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
const wrapper = mountDetail({ eng });
|
|
66
|
+
expect(wrapper.text()).toContain('road');
|
|
67
|
+
expect(wrapper.text()).toContain('Expression');
|
|
68
|
+
expect(wrapper.text()).toContain('Preferred');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('shows definition', () => {
|
|
72
|
+
const eng = makeLocalizedConcept({
|
|
73
|
+
'gl:definition': [{ '@type': 'gl:Definition', 'gl:content': 'A paved surface for vehicles.' }],
|
|
74
|
+
});
|
|
75
|
+
const wrapper = mountDetail({ eng });
|
|
76
|
+
expect(wrapper.text()).toContain('Definition');
|
|
77
|
+
expect(wrapper.text()).toContain('paved surface');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows notes', () => {
|
|
81
|
+
const eng = makeLocalizedConcept({
|
|
82
|
+
'gl:notes': [{ '@type': 'gl:Note', 'gl:content': 'This is a note.' }],
|
|
83
|
+
});
|
|
84
|
+
const wrapper = mountDetail({ eng });
|
|
85
|
+
expect(wrapper.text()).toContain('Notes');
|
|
86
|
+
expect(wrapper.text()).toContain('This is a note');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('shows examples', () => {
|
|
90
|
+
const eng = makeLocalizedConcept({
|
|
91
|
+
'gl:examples': [{ '@type': 'gl:Example', 'gl:content': 'A highway is a road.' }],
|
|
92
|
+
});
|
|
93
|
+
const wrapper = mountDetail({ eng });
|
|
94
|
+
expect(wrapper.text()).toContain('Examples');
|
|
95
|
+
expect(wrapper.text()).toContain('A highway is a road');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('shows sources', () => {
|
|
99
|
+
const eng = makeLocalizedConcept({
|
|
100
|
+
'gl:source': [{ '@type': 'gl:Source', 'gl:sourceType': 'authoritative', 'gl:origin': { '@type': 'gl:Origin', 'gl:ref': 'ISO 7010' } }],
|
|
101
|
+
});
|
|
102
|
+
const wrapper = mountDetail({ eng });
|
|
103
|
+
expect(wrapper.text()).toContain('Sources');
|
|
104
|
+
expect(wrapper.text()).toContain('ISO 7010');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('shows term-only state for language without definition', () => {
|
|
108
|
+
const eng = makeLocalizedConcept({
|
|
109
|
+
'gl:designation': [{ '@type': 'gl:Expression', 'gl:term': 'test', 'gl:normativeStatus': 'preferred' }],
|
|
110
|
+
});
|
|
111
|
+
delete (eng as any)['gl:definition'];
|
|
112
|
+
delete (eng as any)['gl:notes'];
|
|
113
|
+
delete (eng as any)['gl:examples'];
|
|
114
|
+
const wrapper = mountDetail({ eng });
|
|
115
|
+
expect(wrapper.text()).toContain('Term only in English');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('shows no data message for missing language', () => {
|
|
119
|
+
const eng = makeLocalizedConcept();
|
|
120
|
+
const wrapper = mountDetail({ eng }, 'zho');
|
|
121
|
+
expect(wrapper.text()).toContain('No data available');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('shows designation type badges', () => {
|
|
125
|
+
const eng = makeLocalizedConcept({
|
|
126
|
+
'gl:designation': [
|
|
127
|
+
{ '@type': 'gl:Symbol', 'gl:term': 'H₂O', 'gl:normativeStatus': 'preferred' },
|
|
128
|
+
{ '@type': 'gl:Abbreviation', 'gl:term': 'abbr', 'gl:normativeStatus': 'admitted' },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
const wrapper = mountDetail({ eng });
|
|
132
|
+
expect(wrapper.text()).toContain('Symbol');
|
|
133
|
+
expect(wrapper.text()).toContain('Abbreviation');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('shows gender and plurality when present', () => {
|
|
137
|
+
const eng = makeLocalizedConcept({
|
|
138
|
+
'gl:designation': [
|
|
139
|
+
{ '@type': 'gl:Expression', 'gl:term': 'route', 'gl:normativeStatus': 'preferred', 'gl:gender': 'f', 'gl:plurality': 'singular' },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
const wrapper = mountDetail({ eng });
|
|
143
|
+
expect(wrapper.text()).toContain('f');
|
|
144
|
+
expect(wrapper.text()).toContain('singular');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderMarkdown } from '../utils/markdown-lite';
|
|
3
|
+
|
|
4
|
+
describe('renderMarkdown', () => {
|
|
5
|
+
it('returns empty string for empty input', () => {
|
|
6
|
+
expect(renderMarkdown('')).toBe('');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('wraps plain text in <p>', () => {
|
|
10
|
+
expect(renderMarkdown('Hello world')).toBe('<p>Hello world</p>');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('creates separate paragraphs on blank lines', () => {
|
|
14
|
+
const result = renderMarkdown('First\n\nSecond');
|
|
15
|
+
expect(result).toContain('<p>First</p>');
|
|
16
|
+
expect(result).toContain('<p>Second</p>');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders headings (level + 1, h1 reserved)', () => {
|
|
20
|
+
expect(renderMarkdown('## Heading 2')).toContain('<h3>Heading 2</h3>');
|
|
21
|
+
expect(renderMarkdown('### Heading 3')).toContain('<h4>Heading 3</h4>');
|
|
22
|
+
expect(renderMarkdown('#### Heading 4')).toContain('<h5>Heading 4</h5>');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders bold text', () => {
|
|
26
|
+
expect(renderMarkdown('some **bold** text')).toContain('<strong>bold</strong>');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders italic text', () => {
|
|
30
|
+
expect(renderMarkdown('some *italic* text')).toContain('<em>italic</em>');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders inline code', () => {
|
|
34
|
+
expect(renderMarkdown('use `code` here')).toContain('<code>code</code>');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders markdown links', () => {
|
|
38
|
+
const result = renderMarkdown('[label](https://example.com)');
|
|
39
|
+
expect(result).toContain('<a href="https://example.com"');
|
|
40
|
+
expect(result).toContain('>label</a>');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('renders unordered lists', () => {
|
|
44
|
+
const result = renderMarkdown('- one\n- two');
|
|
45
|
+
expect(result).toContain('<ul>');
|
|
46
|
+
expect(result).toContain('<li>one</li>');
|
|
47
|
+
expect(result).toContain('<li>two</li>');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders ordered lists', () => {
|
|
51
|
+
const result = renderMarkdown('1. first\n2. second');
|
|
52
|
+
expect(result).toContain('<ol>');
|
|
53
|
+
expect(result).toContain('<li>first</li>');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('renders blockquotes', () => {
|
|
57
|
+
const result = renderMarkdown('> quoted text');
|
|
58
|
+
expect(result).toContain('<blockquote>');
|
|
59
|
+
expect(result).toContain('quoted text');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders code fences', () => {
|
|
63
|
+
const result = renderMarkdown('```\nlet x = 1;\n```');
|
|
64
|
+
expect(result).toContain('<pre><code>');
|
|
65
|
+
expect(result).toContain('let x = 1;');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders code fences with language', () => {
|
|
69
|
+
const result = renderMarkdown('```js\nconst x = 1;\n```');
|
|
70
|
+
expect(result).toContain('class="language-js"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('escapes HTML in code fences', () => {
|
|
74
|
+
const result = renderMarkdown('```\n<a href="evil">\n```');
|
|
75
|
+
expect(result).toContain('<a href="evil">');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders horizontal rules', () => {
|
|
79
|
+
const result = renderMarkdown('---');
|
|
80
|
+
expect(result).toContain('<hr>');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('handles multi-line paragraphs', () => {
|
|
84
|
+
const result = renderMarkdown('line one\nline two\n\nnew paragraph');
|
|
85
|
+
expect(result).toContain('<p>line one line two</p>');
|
|
86
|
+
expect(result).toContain('<p>new paragraph</p>');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import NavIcon from '../components/NavIcon.vue';
|
|
4
|
+
|
|
5
|
+
describe('NavIcon', () => {
|
|
6
|
+
it('renders an SVG element', () => {
|
|
7
|
+
const wrapper = mount(NavIcon, { props: { name: 'home' } });
|
|
8
|
+
expect(wrapper.find('svg').exists()).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders home icon path', () => {
|
|
12
|
+
const wrapper = mount(NavIcon, { props: { name: 'home' } });
|
|
13
|
+
const path = wrapper.find('svg path');
|
|
14
|
+
expect(path.exists()).toBe(true);
|
|
15
|
+
expect(path.attributes('d')).toBeTruthy();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders search icon', () => {
|
|
19
|
+
const wrapper = mount(NavIcon, { props: { name: 'search' } });
|
|
20
|
+
expect(wrapper.find('svg').exists()).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('renders graph icon', () => {
|
|
24
|
+
const wrapper = mount(NavIcon, { props: { name: 'graph' } });
|
|
25
|
+
expect(wrapper.find('svg').exists()).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('falls back to info icon for unknown name', () => {
|
|
29
|
+
const wrapper = mount(NavIcon, { props: { name: 'nonexistent' } });
|
|
30
|
+
expect(wrapper.find('svg').exists()).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('applies correct CSS classes', () => {
|
|
34
|
+
const wrapper = mount(NavIcon, { props: { name: 'home' } });
|
|
35
|
+
const svg = wrapper.find('svg');
|
|
36
|
+
expect(svg.classes()).toContain('w-4');
|
|
37
|
+
expect(svg.classes()).toContain('h-4');
|
|
38
|
+
expect(svg.classes()).toContain('text-ink-400');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders all known icon types', () => {
|
|
42
|
+
const icons = ['home', 'search', 'graph', 'newspaper', 'users', 'info', 'chart', 'list'];
|
|
43
|
+
for (const name of icons) {
|
|
44
|
+
const wrapper = mount(NavIcon, { props: { name } });
|
|
45
|
+
expect(wrapper.find('svg').exists()).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
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 NewsView from '../views/NewsView.vue';
|
|
6
|
+
|
|
7
|
+
async function createTestRouter() {
|
|
8
|
+
const router = createRouter({
|
|
9
|
+
history: createMemoryHistory(),
|
|
10
|
+
routes: [
|
|
11
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
12
|
+
{ path: '/news', name: 'news', component: { template: '<div/>' } },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
15
|
+
router.push('/news');
|
|
16
|
+
await router.isReady();
|
|
17
|
+
return router;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('NewsView', () => {
|
|
21
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
22
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
pinia = createPinia();
|
|
26
|
+
setActivePinia(pinia);
|
|
27
|
+
router = await createTestRouter();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function mountNews() {
|
|
31
|
+
return mount(NewsView, {
|
|
32
|
+
global: { plugins: [pinia, router] },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('renders breadcrumb navigation', () => {
|
|
37
|
+
const wrapper = mountNews();
|
|
38
|
+
expect(wrapper.text()).toContain('Home');
|
|
39
|
+
expect(wrapper.text()).toContain('News');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders News heading', () => {
|
|
43
|
+
const wrapper = mountNews();
|
|
44
|
+
expect(wrapper.text()).toContain('News');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('shows loading skeleton initially', () => {
|
|
48
|
+
const wrapper = mountNews();
|
|
49
|
+
expect(wrapper.find('.animate-pulse').exists()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('shows empty state when no posts', async () => {
|
|
53
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]) });
|
|
54
|
+
const wrapper = mountNews();
|
|
55
|
+
await flushPromises();
|
|
56
|
+
expect(wrapper.text()).toContain('No news posts yet');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('shows error state when fetch fails', async () => {
|
|
60
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
61
|
+
const wrapper = mountNews();
|
|
62
|
+
await flushPromises();
|
|
63
|
+
expect(wrapper.text()).toContain('Failed to load news posts');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('renders posts when available', async () => {
|
|
67
|
+
const posts = [
|
|
68
|
+
{ slug: 'test-post', title: 'Test Post', date: '2025-01-15', categories: ['release'], file: '/news/test-post.adoc', excerpt: 'A test excerpt.' },
|
|
69
|
+
];
|
|
70
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(posts) });
|
|
71
|
+
const wrapper = mountNews();
|
|
72
|
+
await flushPromises();
|
|
73
|
+
expect(wrapper.text()).toContain('Test Post');
|
|
74
|
+
expect(wrapper.text()).toContain('release');
|
|
75
|
+
expect(wrapper.text()).toContain('A test excerpt.');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('formats dates correctly', async () => {
|
|
79
|
+
const posts = [
|
|
80
|
+
{ slug: 'dated', title: 'Dated', date: '2025-03-15', categories: [], file: '/news/dated.adoc', excerpt: '' },
|
|
81
|
+
];
|
|
82
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(posts) });
|
|
83
|
+
const wrapper = mountNews();
|
|
84
|
+
await flushPromises();
|
|
85
|
+
expect(wrapper.text()).toContain('March 15, 2025');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
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 PageView from '../views/PageView.vue';
|
|
6
|
+
|
|
7
|
+
async function createTestRouter(path = '/pages/test-page') {
|
|
8
|
+
const router = createRouter({
|
|
9
|
+
history: createMemoryHistory(),
|
|
10
|
+
routes: [
|
|
11
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
12
|
+
{ path: '/pages/:slug', name: 'page', component: { template: '<div/>' } },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
15
|
+
router.push(path);
|
|
16
|
+
await router.isReady();
|
|
17
|
+
return router;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('PageView', () => {
|
|
21
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
22
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
pinia = createPinia();
|
|
26
|
+
setActivePinia(pinia);
|
|
27
|
+
router = await createTestRouter();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function mountPage() {
|
|
31
|
+
return mount(PageView, {
|
|
32
|
+
global: { plugins: [pinia, router] },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('shows loading skeleton initially', () => {
|
|
37
|
+
const wrapper = mountPage();
|
|
38
|
+
expect(wrapper.find('.animate-pulse').exists()).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders breadcrumb navigation', () => {
|
|
42
|
+
const wrapper = mountPage();
|
|
43
|
+
expect(wrapper.text()).toContain('Home');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('shows page not found when fetch fails', async () => {
|
|
47
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false });
|
|
48
|
+
const wrapper = mountPage();
|
|
49
|
+
await flushPromises();
|
|
50
|
+
expect(wrapper.text()).toContain('Page Not Found');
|
|
51
|
+
expect(wrapper.text()).toContain('test-page');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('shows go home link on not found', async () => {
|
|
55
|
+
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false });
|
|
56
|
+
const wrapper = mountPage();
|
|
57
|
+
await flushPromises();
|
|
58
|
+
const homeLink = wrapper.findAll('a').find(a => a.text() === 'Go Home');
|
|
59
|
+
expect(homeLink).toBeDefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders page content when loaded', async () => {
|
|
63
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
64
|
+
ok: true,
|
|
65
|
+
json: () => Promise.resolve({ title: 'About Us', html: '<p>Hello world</p>' }),
|
|
66
|
+
});
|
|
67
|
+
const wrapper = mountPage();
|
|
68
|
+
await flushPromises();
|
|
69
|
+
expect(wrapper.text()).toContain('About Us');
|
|
70
|
+
expect(wrapper.text()).toContain('Hello world');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders page title in breadcrumb', async () => {
|
|
74
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
75
|
+
ok: true,
|
|
76
|
+
json: () => Promise.resolve({ title: 'About Us', html: '<p>Content</p>' }),
|
|
77
|
+
});
|
|
78
|
+
const wrapper = mountPage();
|
|
79
|
+
await flushPromises();
|
|
80
|
+
const breadcrumb = wrapper.find('nav[aria-label="Breadcrumb"]');
|
|
81
|
+
expect(breadcrumb.text()).toContain('About Us');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock @plurimath/plurimath to avoid loading the 2.7MB Opal runtime in tests
|
|
4
|
+
vi.mock('@plurimath/plurimath', () => ({
|
|
5
|
+
default: class MockPlurimath {
|
|
6
|
+
constructor(private data: string, private format: string) {}
|
|
7
|
+
toMathml() {
|
|
8
|
+
if (this.data === 'ERROR') throw new Error('parse error');
|
|
9
|
+
return `<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mi>${this.data}</mi></math>`;
|
|
10
|
+
}
|
|
11
|
+
toAsciimath() { return this.data; }
|
|
12
|
+
toLatex() { return this.data; }
|
|
13
|
+
toHtml() { return this.data; }
|
|
14
|
+
toOmml() { return this.data; }
|
|
15
|
+
toDisplay() { return this.data; }
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { loadPlurimath, renderToMathML, mathToHtml } from '../utils/plurimath';
|
|
20
|
+
|
|
21
|
+
describe('loadPlurimath', () => {
|
|
22
|
+
it('loads and returns the Plurimath class', async () => {
|
|
23
|
+
const Plurimath = await loadPlurimath();
|
|
24
|
+
expect(Plurimath).toBeDefined();
|
|
25
|
+
const p = new Plurimath('x', 'asciimath');
|
|
26
|
+
expect(p.toMathml()).toContain('<math');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns the same instance on subsequent calls', async () => {
|
|
30
|
+
const a = await loadPlurimath();
|
|
31
|
+
const b = await loadPlurimath();
|
|
32
|
+
expect(a).toBe(b);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('renderToMathML', () => {
|
|
37
|
+
it('returns MathML with inline display after loading', async () => {
|
|
38
|
+
await loadPlurimath();
|
|
39
|
+
const result = renderToMathML('x^2', 'asciimath');
|
|
40
|
+
expect(result).toContain('<math');
|
|
41
|
+
expect(result).toContain('display="inline"');
|
|
42
|
+
expect(result).not.toContain('display="block"');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns null on parse error', async () => {
|
|
46
|
+
await loadPlurimath();
|
|
47
|
+
expect(renderToMathML('ERROR', 'asciimath')).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('mathToHtml', () => {
|
|
52
|
+
it('wraps MathML in math-inline span', async () => {
|
|
53
|
+
await loadPlurimath();
|
|
54
|
+
const result = mathToHtml('x', 'asciimath', false);
|
|
55
|
+
expect(result).toContain('class="math-inline"');
|
|
56
|
+
expect(result).toContain('<math');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('adds math-bold class when bold is true', async () => {
|
|
60
|
+
await loadPlurimath();
|
|
61
|
+
const result = mathToHtml('x', 'asciimath', true);
|
|
62
|
+
expect(result).toContain('class="math-inline math-bold"');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns fallback code element on error', async () => {
|
|
66
|
+
await loadPlurimath();
|
|
67
|
+
const result = mathToHtml('ERROR', 'asciimath', false);
|
|
68
|
+
expect(result).toContain('class="math-fallback"');
|
|
69
|
+
expect(result).toContain('ERROR');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { resetFactory, getFactory } from '../adapters/factory';
|
|
6
|
+
import ResolveView from '../views/ResolveView.vue';
|
|
7
|
+
|
|
8
|
+
const TEST_URI = 'https://glossarist.org/test/concept/1';
|
|
9
|
+
|
|
10
|
+
async function createTestRouter(uri = TEST_URI) {
|
|
11
|
+
const router = createRouter({
|
|
12
|
+
history: createMemoryHistory(),
|
|
13
|
+
routes: [
|
|
14
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
15
|
+
{ path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
|
|
16
|
+
{ path: '/resolve/:uri(.*)', name: 'resolve', component: { template: '<div/>' } },
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
router.push(`/resolve/${encodeURIComponent(uri)}`);
|
|
20
|
+
await router.isReady();
|
|
21
|
+
return router;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ResolveView', () => {
|
|
25
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
26
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
resetFactory();
|
|
30
|
+
pinia = createPinia();
|
|
31
|
+
setActivePinia(pinia);
|
|
32
|
+
router = await createTestRouter();
|
|
33
|
+
// Pre-seed the factory with a stub adapter so discoverDatasets is skipped
|
|
34
|
+
const factory = getFactory();
|
|
35
|
+
const adapter = { registerId: 'test', manifest: null };
|
|
36
|
+
(factory as any).adapters.set('test', adapter);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function mountResolve() {
|
|
40
|
+
return mount(ResolveView, {
|
|
41
|
+
global: { plugins: [pinia, router] },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it('shows resolving message while loading', () => {
|
|
46
|
+
const wrapper = mountResolve();
|
|
47
|
+
expect(wrapper.text()).toContain('Resolving...');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shows error when concept not found', async () => {
|
|
51
|
+
const wrapper = mountResolve();
|
|
52
|
+
await flushPromises();
|
|
53
|
+
expect(wrapper.text()).toContain('Concept not found');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('displays the URI being resolved', async () => {
|
|
57
|
+
const wrapper = mountResolve();
|
|
58
|
+
await flushPromises();
|
|
59
|
+
expect(wrapper.text()).toContain('glossarist.org/test/concept/1');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('shows return to home link on error', async () => {
|
|
63
|
+
const wrapper = mountResolve();
|
|
64
|
+
await flushPromises();
|
|
65
|
+
const link = wrapper.findAll('a').find(a => a.text().includes('Return to home'));
|
|
66
|
+
expect(link).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('resolves the URI via the factory resolver', async () => {
|
|
70
|
+
const factory = getFactory();
|
|
71
|
+
factory.resolver.registerDataset('test', ['https://glossarist.org/test/*']);
|
|
72
|
+
const resolution = factory.resolve(TEST_URI);
|
|
73
|
+
expect(resolution.type).toBe('internal');
|
|
74
|
+
expect(resolution).toHaveProperty('registerId', 'test');
|
|
75
|
+
expect(resolution).toHaveProperty('conceptId', '1');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { routes } from '../router/index';
|
|
3
|
+
|
|
4
|
+
// We just need the route definitions, not a full router instance
|
|
5
|
+
const routeMap = new Map(routes.map(r => [r.name as string, r]));
|
|
6
|
+
|
|
7
|
+
describe('Router route definitions', () => {
|
|
8
|
+
it('defines all expected routes', () => {
|
|
9
|
+
const names = routes.map(r => r.name).filter(Boolean);
|
|
10
|
+
expect(names).toContain('home');
|
|
11
|
+
expect(names).toContain('dataset');
|
|
12
|
+
expect(names).toContain('concept');
|
|
13
|
+
expect(names).toContain('stats');
|
|
14
|
+
expect(names).toContain('about');
|
|
15
|
+
expect(names).toContain('search');
|
|
16
|
+
expect(names).toContain('graph');
|
|
17
|
+
expect(names).toContain('resolve');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('uses correct path patterns', () => {
|
|
21
|
+
expect(routeMap.get('home')!.path).toBe('/');
|
|
22
|
+
expect(routeMap.get('dataset')!.path).toBe('/dataset/:registerId');
|
|
23
|
+
expect(routeMap.get('concept')!.path).toBe('/dataset/:registerId/concept/:conceptId');
|
|
24
|
+
expect(routeMap.get('stats')!.path).toBe('/dataset/:registerId/stats');
|
|
25
|
+
expect(routeMap.get('about')!.path).toBe('/dataset/:registerId/about');
|
|
26
|
+
expect(routeMap.get('search')!.path).toBe('/search');
|
|
27
|
+
expect(routeMap.get('graph')!.path).toBe('/graph');
|
|
28
|
+
expect(routeMap.get('resolve')!.path).toBe('/resolve/:uri(.*)');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('dataset and concept routes use props: true', () => {
|
|
32
|
+
expect(routeMap.get('dataset')!.props).toBe(true);
|
|
33
|
+
expect(routeMap.get('concept')!.props).toBe(true);
|
|
34
|
+
expect(routeMap.get('stats')!.props).toBe(true);
|
|
35
|
+
expect(routeMap.get('about')!.props).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('resolve route accepts any URI with wildcard', () => {
|
|
39
|
+
const resolve = routeMap.get('resolve')!;
|
|
40
|
+
expect(resolve.path).toContain(':uri(.*)');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('uses lazy-loaded components for all routes', () => {
|
|
44
|
+
for (const route of routes) {
|
|
45
|
+
if (route.component && typeof route.component === 'function') {
|
|
46
|
+
// Dynamic import returns a function
|
|
47
|
+
expect(route.component).toBeTypeOf('function');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('defines dataset-page catch-all after specific routes', () => {
|
|
53
|
+
const names = routes.map(r => r.name);
|
|
54
|
+
const dsIdx = names.indexOf('dataset');
|
|
55
|
+
const dsPageIdx = names.indexOf('dataset-page');
|
|
56
|
+
expect(dsIdx).toBeGreaterThan(-1);
|
|
57
|
+
expect(dsPageIdx).toBeGreaterThan(dsIdx);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('defines page slug catch-all as last route', () => {
|
|
61
|
+
const last = routes[routes.length - 1];
|
|
62
|
+
expect(last.name).toBe('page');
|
|
63
|
+
expect(last.path).toBe('/:slug');
|
|
64
|
+
});
|
|
65
|
+
});
|