@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,219 @@
1
+ import { describe, it, expect, beforeEach, afterEach, 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 SearchBar from '../components/SearchBar.vue';
6
+ import { useVocabularyStore } from '../stores/vocabulary';
7
+ import { useUiStore } from '../stores/ui';
8
+ import type { Manifest, SearchHit } from '../adapters/types';
9
+
10
+ function makeManifest(): Manifest {
11
+ return {
12
+ id: 'test',
13
+ datasetUri: 'https://glossarist.org/test/concept',
14
+ title: 'Test Dataset',
15
+ description: 'A test dataset',
16
+ owner: 'ISO',
17
+ baseUrl: '/data/test',
18
+ languages: ['eng'],
19
+ conceptCount: 10,
20
+ conceptUrlTemplate: '/data/test/concepts/{id}.json',
21
+ indexUrl: '/data/test/index.json',
22
+ contextUrl: '/data/test/context.json',
23
+ uriBase: 'https://glossarist.org',
24
+ status: 'published',
25
+ schemaVersion: '1.0',
26
+ tags: [],
27
+ lastUpdated: '2025-01-01',
28
+ sourceRepo: 'https://example.com/repo',
29
+ chunkSize: 1000,
30
+ color: '#3366ff',
31
+ };
32
+ }
33
+
34
+ function makeHit(overrides: Partial<SearchHit> = {}): SearchHit {
35
+ return {
36
+ conceptId: '3.1.1.1',
37
+ registerId: 'test',
38
+ designation: 'test term',
39
+ language: 'eng',
40
+ matchField: 'designation',
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ async function createTestRouter() {
46
+ return createRouter({
47
+ history: createMemoryHistory(),
48
+ routes: [
49
+ { path: '/', name: 'home', component: { template: '<div/>' } },
50
+ { path: '/search', name: 'search', component: { template: '<div/>' } },
51
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
52
+ ],
53
+ });
54
+ }
55
+
56
+ describe('SearchBar', () => {
57
+ let pinia: ReturnType<typeof createPinia>;
58
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
59
+ let store: ReturnType<typeof useVocabularyStore>;
60
+
61
+ beforeEach(async () => {
62
+ pinia = createPinia();
63
+ setActivePinia(pinia);
64
+ router = await createTestRouter();
65
+ router.push('/search');
66
+ await router.isReady();
67
+ store = useVocabularyStore();
68
+ store.manifests.set('test', makeManifest());
69
+ store.datasets.set('test', { index: [], getConceptCount: () => 0, getConcepts: () => [], search: () => [] } as any);
70
+ vi.useFakeTimers({ shouldAdvanceTime: true });
71
+ });
72
+
73
+ afterEach(() => {
74
+ vi.useRealTimers();
75
+ });
76
+
77
+ function mountSearch() {
78
+ return mount(SearchBar, {
79
+ global: { plugins: [pinia, router] },
80
+ });
81
+ }
82
+
83
+ it('renders search input with placeholder', () => {
84
+ const wrapper = mountSearch();
85
+ const input = wrapper.find('input');
86
+ expect(input.exists()).toBe(true);
87
+ expect(input.attributes('placeholder')).toContain('Search terms');
88
+ });
89
+
90
+ it('shows clear button when query is entered', async () => {
91
+ const wrapper = mountSearch();
92
+ const input = wrapper.find('input');
93
+ await input.setValue('road');
94
+ const clearBtn = wrapper.findAll('button').find(b => b.element.closest('.relative')?.querySelector('input'));
95
+ // There should be a close/X button visible
96
+ expect(wrapper.html()).toContain('M6 18L18 6M6 6l12 12');
97
+ });
98
+
99
+ it('clears search on clear button click', async () => {
100
+ const wrapper = mountSearch();
101
+ const input = wrapper.find('input');
102
+ await input.setValue('road');
103
+ // Find and click the clear button (the one inside the relative div, after the input)
104
+ const clearBtn = wrapper.findAll('button').find(b => b.text() === '' && b.find('svg').exists() && b.element.closest('.relative') !== null);
105
+ if (clearBtn) {
106
+ await clearBtn.trigger('click');
107
+ expect(wrapper.find('input').element.value).toBe('');
108
+ }
109
+ });
110
+
111
+ it('shows loading spinner during search', async () => {
112
+ const wrapper = mountSearch();
113
+ // Mock a slow search
114
+ store.searchAcrossDatasets = vi.fn(() => new Promise<SearchHit[]>(() => {}));
115
+ const input = wrapper.find('input');
116
+ await input.setValue('road');
117
+ const form = wrapper.find('form');
118
+ await form.trigger('submit');
119
+ await flushPromises();
120
+ // Should show spinner SVG
121
+ expect(wrapper.html()).toContain('animate-spin');
122
+ });
123
+
124
+ it('shows results after search', async () => {
125
+ const hits: SearchHit[] = [
126
+ makeHit({ conceptId: '3.1.1.1', designation: 'road network' }),
127
+ makeHit({ conceptId: '3.1.1.2', designation: 'road user' }),
128
+ ];
129
+ store.searchAcrossDatasets = vi.fn(async () => hits);
130
+ const wrapper = mountSearch();
131
+ const input = wrapper.find('input');
132
+ await input.setValue('road');
133
+ await wrapper.find('form').trigger('submit');
134
+ await flushPromises();
135
+ expect(wrapper.text()).toContain('2 results');
136
+ expect(wrapper.text()).toContain('road network');
137
+ expect(wrapper.text()).toContain('road user');
138
+ });
139
+
140
+ it('shows empty state when no results', async () => {
141
+ store.searchAcrossDatasets = vi.fn(async () => []);
142
+ const wrapper = mountSearch();
143
+ const input = wrapper.find('input');
144
+ await input.setValue('zzzznonexistent');
145
+ await wrapper.find('form').trigger('submit');
146
+ await flushPromises();
147
+ expect(wrapper.text()).toContain('0 results');
148
+ expect(wrapper.text()).toContain('No concepts found');
149
+ });
150
+
151
+ it('groups results by dataset', async () => {
152
+ const hits: SearchHit[] = [
153
+ makeHit({ registerId: 'test', designation: 'term1' }),
154
+ makeHit({ registerId: 'other', designation: 'term2' }),
155
+ ];
156
+ store.manifests.set('other', { ...makeManifest(), id: 'other', title: 'Other Dataset' });
157
+ store.datasets.set('other', { index: [], getConceptCount: () => 0, getConcepts: () => [] } as any);
158
+ store.searchAcrossDatasets = vi.fn(async () => hits);
159
+ const wrapper = mountSearch();
160
+ await wrapper.find('input').setValue('term');
161
+ await wrapper.find('form').trigger('submit');
162
+ await flushPromises();
163
+ expect(wrapper.text()).toContain('Test Dataset');
164
+ expect(wrapper.text()).toContain('Other Dataset');
165
+ });
166
+
167
+ it('navigates to concept on result click', async () => {
168
+ const hits: SearchHit[] = [makeHit()];
169
+ store.searchAcrossDatasets = vi.fn(async () => hits);
170
+ const wrapper = mountSearch();
171
+ await wrapper.find('input').setValue('road');
172
+ await wrapper.find('form').trigger('submit');
173
+ await flushPromises();
174
+ const hitBtn = wrapper.findAll('button').find(b => b.text().includes('test term'));
175
+ expect(hitBtn).toBeDefined();
176
+ await hitBtn!.trigger('click');
177
+ await flushPromises();
178
+ expect(router.currentRoute.value.name).toBe('concept');
179
+ expect(router.currentRoute.value.params.conceptId).toBe('3.1.1.1');
180
+ });
181
+
182
+ it('shows ID match badge for ID-based matches', async () => {
183
+ const hits: SearchHit[] = [makeHit({ matchField: 'id' })];
184
+ store.searchAcrossDatasets = vi.fn(async () => hits);
185
+ const wrapper = mountSearch();
186
+ await wrapper.find('input').setValue('3.1');
187
+ await wrapper.find('form').trigger('submit');
188
+ await flushPromises();
189
+ expect(wrapper.text()).toContain('ID match');
190
+ });
191
+
192
+ it('updates URL query parameter on search', async () => {
193
+ store.searchAcrossDatasets = vi.fn(async () => []);
194
+ const wrapper = mountSearch();
195
+ await wrapper.find('input').setValue('road');
196
+ await wrapper.find('form').trigger('submit');
197
+ await flushPromises();
198
+ expect(router.currentRoute.value.query.q).toBe('road');
199
+ });
200
+
201
+ it('shows error state on search failure', async () => {
202
+ store.searchAcrossDatasets = vi.fn(async () => { throw new Error('Network error'); });
203
+ const wrapper = mountSearch();
204
+ await wrapper.find('input').setValue('road');
205
+ await wrapper.find('form').trigger('submit');
206
+ await flushPromises();
207
+ expect(wrapper.text()).toContain('Search failed');
208
+ expect(wrapper.text()).toContain('Network error');
209
+ });
210
+
211
+ it('shows retry button on error', async () => {
212
+ store.searchAcrossDatasets = vi.fn(async () => { throw new Error('fail'); });
213
+ const wrapper = mountSearch();
214
+ await wrapper.find('input').setValue('road');
215
+ await wrapper.find('form').trigger('submit');
216
+ await flushPromises();
217
+ expect(wrapper.text()).toContain('Retry');
218
+ });
219
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount, flushPromises } from '@vue/test-utils';
3
+ import SearchView from '../views/SearchView.vue';
4
+ import { useVocabularyStore } from '../stores/vocabulary';
5
+ import { createTestRouter, setupPinia, makeManifest, makeAdapterStub } from './test-helpers';
6
+
7
+ describe('SearchView', () => {
8
+ let pinia: ReturnType<typeof setupPinia>;
9
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
10
+
11
+ beforeEach(async () => {
12
+ pinia = setupPinia();
13
+ router = await createTestRouter('search', '/search');
14
+ const store = useVocabularyStore();
15
+ store.manifests.set('test', makeManifest());
16
+ store.datasets.set('test', makeAdapterStub());
17
+ });
18
+
19
+ function mountView() {
20
+ return mount(SearchView, {
21
+ global: { plugins: [pinia, router], stubs: { SearchBar: true } },
22
+ });
23
+ }
24
+
25
+ it('renders breadcrumb navigation', () => {
26
+ const wrapper = mountView();
27
+ expect(wrapper.text()).toContain('Home');
28
+ expect(wrapper.text()).toContain('Search');
29
+ });
30
+
31
+ it('renders SearchBar component', () => {
32
+ const wrapper = mountView();
33
+ expect(wrapper.findComponent({ name: 'SearchBar' }).exists()).toBe(true);
34
+ });
35
+
36
+ it('renders breadcrumb with Home link', () => {
37
+ const wrapper = mountView();
38
+ const homeLink = wrapper.findAll('a').find(a => a.text() === 'Home');
39
+ expect(homeLink).toBeDefined();
40
+ });
41
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount, flushPromises } from '@vue/test-utils';
3
+ import StatsView from '../views/StatsView.vue';
4
+ import { useVocabularyStore } from '../stores/vocabulary';
5
+ import { createTestRouter, setupPinia, makeManifest, makeAdapterStub } from './test-helpers';
6
+
7
+ const testManifest = makeManifest({
8
+ languages: ['eng', 'fra', 'deu'],
9
+ conceptCount: 100,
10
+ languageStats: {
11
+ eng: { terms: 100, definitions: 95 },
12
+ fra: { terms: 80, definitions: 70 },
13
+ deu: { terms: 60, definitions: 50 },
14
+ },
15
+ });
16
+
17
+ describe('StatsView', () => {
18
+ let pinia: ReturnType<typeof setupPinia>;
19
+ let router: Awaited<ReturnType<typeof createTestRouter>>;
20
+
21
+ beforeEach(async () => {
22
+ pinia = setupPinia();
23
+ router = await createTestRouter('dataset', '/dataset/test/stats');
24
+ const store = useVocabularyStore();
25
+ store.manifests.set('test', testManifest);
26
+ store.datasets.set('test', makeAdapterStub());
27
+ });
28
+
29
+ function mountStats() {
30
+ return mount(StatsView, {
31
+ global: { plugins: [pinia, router] },
32
+ props: { registerId: 'test' },
33
+ });
34
+ }
35
+
36
+ it('renders statistics heading', async () => {
37
+ const wrapper = mountStats();
38
+ await flushPromises();
39
+ expect(wrapper.text()).toContain('Statistics');
40
+ });
41
+
42
+ it('shows total concept count', async () => {
43
+ const wrapper = mountStats();
44
+ await flushPromises();
45
+ expect(wrapper.text()).toContain('100 concepts');
46
+ });
47
+
48
+ it('shows language count', async () => {
49
+ const wrapper = mountStats();
50
+ await flushPromises();
51
+ expect(wrapper.text()).toContain('3 languages');
52
+ });
53
+
54
+ it('shows language stats table', async () => {
55
+ const wrapper = mountStats();
56
+ await flushPromises();
57
+ expect(wrapper.text()).toContain('English');
58
+ expect(wrapper.text()).toContain('French');
59
+ expect(wrapper.text()).toContain('German');
60
+ });
61
+
62
+ it('shows term and definition counts', async () => {
63
+ const wrapper = mountStats();
64
+ await flushPromises();
65
+ expect(wrapper.text()).toContain('100');
66
+ expect(wrapper.text()).toContain('95');
67
+ expect(wrapper.text()).toContain('80');
68
+ });
69
+
70
+ it('renders breadcrumb navigation', async () => {
71
+ const wrapper = mountStats();
72
+ await flushPromises();
73
+ expect(wrapper.text()).toContain('Home');
74
+ expect(wrapper.text()).toContain('Test Dataset');
75
+ expect(wrapper.text()).toContain('Statistics');
76
+ });
77
+ });
@@ -0,0 +1,168 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { createRouter, createMemoryHistory } from 'vue-router';
3
+ import type { Manifest, ConceptSummary, LocalizedConcept, SearchHit } from '../adapters/types';
4
+
5
+ // ── Manifest Factory ──────────────────────────────────────────────────
6
+
7
+ const STUB_COMPONENT = { template: '<div/>' };
8
+
9
+ export 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'],
18
+ conceptCount: 10,
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
+ // ── Adapter Stub ──────────────────────────────────────────────────────
35
+
36
+ export interface AdapterStubOptions {
37
+ concepts?: ConceptSummary[];
38
+ search?: () => SearchHit[];
39
+ fetchConcept?: () => Promise<any>;
40
+ getAdjacentConcepts?: () => { prev: string | null; next: string | null };
41
+ getConceptPosition?: () => number;
42
+ isRangeLoaded?: () => boolean;
43
+ ensureChunksForRange?: () => Promise<void>;
44
+ ensureAllChunksLoaded?: () => Promise<void>;
45
+ extractEdges?: () => any[];
46
+ getIndexEntry?: () => any;
47
+ }
48
+
49
+ export function makeAdapterStub(options: AdapterStubOptions = {}): any {
50
+ return {
51
+ index: options.concepts ?? [],
52
+ manifest: null,
53
+ registerId: 'test',
54
+ getConceptCount: () => (options.concepts ?? []).length,
55
+ getConcepts: () => options.concepts ?? [],
56
+ search: options.search ?? (() => []),
57
+ fetchConcept: options.fetchConcept ?? (() => Promise.resolve(null)),
58
+ getAdjacentConcepts: options.getAdjacentConcepts ?? (() => ({ prev: null, next: null })),
59
+ getConceptPosition: options.getConceptPosition ?? (() => -1),
60
+ isRangeLoaded: options.isRangeLoaded ?? (() => true),
61
+ ensureChunksForRange: options.ensureChunksForRange ?? (() => Promise.resolve()),
62
+ ensureAllChunksLoaded: options.ensureAllChunksLoaded ?? (() => Promise.resolve()),
63
+ extractEdges: options.extractEdges ?? (() => []),
64
+ getIndexEntry: options.getIndexEntry ?? (() => null),
65
+ };
66
+ }
67
+
68
+ // ── Concept Data Factories ────────────────────────────────────────────
69
+
70
+ export function makeLocalizedConcept(overrides: Partial<LocalizedConcept> = {}): LocalizedConcept {
71
+ return {
72
+ '@id': 'https://glossarist.org/test/concept/1/eng',
73
+ '@type': 'gl:LocalizedConcept',
74
+ 'gl:languageCode': 'eng',
75
+ 'gl:entryStatus': 'valid',
76
+ ...overrides,
77
+ };
78
+ }
79
+
80
+ export function makeConceptSummary(overrides: Partial<ConceptSummary> = {}): ConceptSummary {
81
+ return {
82
+ id: '1',
83
+ eng: 'test concept',
84
+ status: 'valid',
85
+ ...overrides,
86
+ };
87
+ }
88
+
89
+ export function makeSearchHit(overrides: Partial<SearchHit> = {}): SearchHit {
90
+ return {
91
+ conceptId: '1',
92
+ registerId: 'test',
93
+ designation: 'test',
94
+ language: 'eng',
95
+ matchField: 'designation',
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ // ── Router Factory ────────────────────────────────────────────────────
101
+
102
+ export type RouteSet = 'minimal' | 'search' | 'dataset' | 'full' | 'resolve' | 'news' | 'pages' | 'contributors' | 'graph';
103
+
104
+ const ROUTE_SETS: Record<RouteSet, any[]> = {
105
+ minimal: [{ path: '/', name: 'home', component: STUB_COMPONENT }],
106
+ search: [
107
+ { path: '/', name: 'home', component: STUB_COMPONENT },
108
+ { path: '/search', name: 'search', component: STUB_COMPONENT },
109
+ ],
110
+ dataset: [
111
+ { path: '/', name: 'home', component: STUB_COMPONENT },
112
+ { path: '/dataset/:registerId', name: 'dataset', component: STUB_COMPONENT },
113
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: STUB_COMPONENT },
114
+ { path: '/dataset/:registerId/stats', name: 'stats', component: STUB_COMPONENT },
115
+ { path: '/dataset/:registerId/about', name: 'about', component: STUB_COMPONENT },
116
+ ],
117
+ full: [
118
+ { path: '/', name: 'home', component: STUB_COMPONENT },
119
+ { path: '/dataset/:registerId', name: 'dataset', component: STUB_COMPONENT },
120
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: STUB_COMPONENT },
121
+ { path: '/dataset/:registerId/stats', name: 'stats', component: STUB_COMPONENT },
122
+ { path: '/dataset/:registerId/about', name: 'about', component: STUB_COMPONENT },
123
+ { path: '/search', name: 'search', component: STUB_COMPONENT },
124
+ { path: '/graph', name: 'graph', component: STUB_COMPONENT },
125
+ ],
126
+ resolve: [
127
+ { path: '/', name: 'home', component: STUB_COMPONENT },
128
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: STUB_COMPONENT },
129
+ { path: '/resolve/:uri(.*)', name: 'resolve', component: STUB_COMPONENT },
130
+ ],
131
+ news: [
132
+ { path: '/', name: 'home', component: STUB_COMPONENT },
133
+ { path: '/news', name: 'news', component: STUB_COMPONENT },
134
+ ],
135
+ pages: [
136
+ { path: '/', name: 'home', component: STUB_COMPONENT },
137
+ { path: '/pages/:slug', name: 'page', component: STUB_COMPONENT },
138
+ ],
139
+ contributors: [
140
+ { path: '/', name: 'home', component: STUB_COMPONENT },
141
+ { path: '/contributors', name: 'contributors', component: STUB_COMPONENT },
142
+ ],
143
+ graph: [
144
+ { path: '/', name: 'home', component: STUB_COMPONENT },
145
+ { path: '/graph', name: 'graph', component: STUB_COMPONENT },
146
+ ],
147
+ };
148
+
149
+ export async function createTestRouter(
150
+ routeSet: RouteSet = 'minimal',
151
+ initialPath = '/',
152
+ ) {
153
+ const router = createRouter({
154
+ history: createMemoryHistory(),
155
+ routes: ROUTE_SETS[routeSet],
156
+ });
157
+ router.push(initialPath);
158
+ await router.isReady();
159
+ return router;
160
+ }
161
+
162
+ // ── Pinia Setup ───────────────────────────────────────────────────────
163
+
164
+ export function setupPinia() {
165
+ const pinia = createPinia();
166
+ setActivePinia(pinia);
167
+ return pinia;
168
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { setActivePinia, createPinia } from 'pinia';
3
+ import { useUiStore, type Theme } from '../stores/ui';
4
+
5
+ describe('useUiStore', () => {
6
+ beforeEach(() => {
7
+ setActivePinia(createPinia());
8
+ localStorage.clear();
9
+ document.documentElement.classList.remove('dark');
10
+ });
11
+
12
+ it('initializes with sidebar closed', () => {
13
+ const ui = useUiStore();
14
+ expect(ui.sidebarOpen).toBe(false);
15
+ });
16
+
17
+ it('initializes with eng language selected', () => {
18
+ const ui = useUiStore();
19
+ expect(ui.selectedLang).toBe('eng');
20
+ });
21
+
22
+ it('initializes with empty search query', () => {
23
+ const ui = useUiStore();
24
+ expect(ui.searchQuery).toBe('');
25
+ });
26
+
27
+ it('toggles sidebar open/closed', () => {
28
+ const ui = useUiStore();
29
+ expect(ui.sidebarOpen).toBe(false);
30
+ ui.toggleSidebar();
31
+ expect(ui.sidebarOpen).toBe(true);
32
+ ui.toggleSidebar();
33
+ expect(ui.sidebarOpen).toBe(false);
34
+ });
35
+
36
+ it('sets language', () => {
37
+ const ui = useUiStore();
38
+ ui.setLang('fra');
39
+ expect(ui.selectedLang).toBe('fra');
40
+ });
41
+
42
+ it('sets search query', () => {
43
+ const ui = useUiStore();
44
+ ui.searchQuery = 'test query';
45
+ expect(ui.searchQuery).toBe('test query');
46
+ });
47
+
48
+ it('defaults theme to system', () => {
49
+ const ui = useUiStore();
50
+ expect(ui.themePref).toBe('system');
51
+ });
52
+
53
+ it('setTheme updates preference and localStorage', () => {
54
+ const ui = useUiStore();
55
+ ui.setTheme('dark');
56
+ expect(ui.themePref).toBe('dark');
57
+ expect(localStorage.getItem('theme')).toBe('dark');
58
+ });
59
+
60
+ it('setTheme to light removes dark class', () => {
61
+ const ui = useUiStore();
62
+ document.documentElement.classList.add('dark');
63
+ ui.setTheme('light');
64
+ expect(document.documentElement.classList.contains('dark')).toBe(false);
65
+ });
66
+
67
+ it('toggleTheme switches from light to dark', () => {
68
+ const ui = useUiStore();
69
+ ui.setTheme('light');
70
+ ui.toggleTheme();
71
+ expect(ui.themePref).toBe('dark');
72
+ });
73
+
74
+ it('toggleTheme switches from dark to light', () => {
75
+ const ui = useUiStore();
76
+ ui.setTheme('dark');
77
+ ui.toggleTheme();
78
+ expect(ui.themePref).toBe('light');
79
+ });
80
+
81
+ it('reads stored theme from localStorage', () => {
82
+ localStorage.setItem('theme', 'dark');
83
+ // Need a new pinia to re-create the store
84
+ setActivePinia(createPinia());
85
+ const ui = useUiStore();
86
+ expect(ui.themePref).toBe('dark');
87
+ });
88
+
89
+ it('isDark is true when theme is dark', () => {
90
+ const ui = useUiStore();
91
+ ui.setTheme('dark');
92
+ expect(ui.isDark).toBe(true);
93
+ });
94
+
95
+ it('isDark is false when theme is light', () => {
96
+ const ui = useUiStore();
97
+ ui.setTheme('light');
98
+ expect(ui.isDark).toBe(false);
99
+ });
100
+ });
@@ -19,6 +19,8 @@ class MockPlurimath {
19
19
  import { vMath } from '../directives/v-math';
20
20
  import { loadPlurimath, mathToHtml } from '../utils/plurimath';
21
21
 
22
+ const directive = vMath as import('vue').ObjectDirective<HTMLElement>;
23
+
22
24
  describe('v-math directive', () => {
23
25
  let container: HTMLElement;
24
26
 
@@ -29,21 +31,20 @@ describe('v-math directive', () => {
29
31
 
30
32
  it('does nothing when no math-pending elements exist', () => {
31
33
  container.innerHTML = '<p>plain text</p>';
32
- vMath.mounted!(container, {} as any);
34
+ directive.mounted!(container, {} as any, {} as any, {} as any);
33
35
  expect(container.innerHTML).toBe('<p>plain text</p>');
34
36
  });
35
37
 
36
38
  it('triggers loadPlurimath when math-pending elements exist', async () => {
37
39
  container.innerHTML = '<span class="math-pending" data-expr="x^2" data-format="asciimath">x^2</span>';
38
- vMath.mounted!(container, {} as any);
40
+ directive.mounted!(container, {} as any, {} as any, {} as any);
39
41
  expect(loadPlurimath).toHaveBeenCalled();
40
42
  });
41
43
 
42
44
  it('replaces math-pending elements after loading', async () => {
43
45
  container.innerHTML = '<span class="math-pending" data-expr="x^2" data-format="asciimath">x^2</span>';
44
- vMath.mounted!(container, {} as any);
46
+ directive.mounted!(container, {} as any, {} as any, {} as any);
45
47
 
46
- // Wait for loadPlurimath promise to resolve and upgrade to run
47
48
  await vi.waitFor(() => {
48
49
  expect(mathToHtml).toHaveBeenCalledWith('x^2', 'asciimath', false);
49
50
  });
@@ -51,7 +52,7 @@ describe('v-math directive', () => {
51
52
 
52
53
  it('handles bold math-pending elements', async () => {
53
54
  container.innerHTML = '<span class="math-pending math-bold" data-expr="alpha" data-format="asciimath">alpha</span>';
54
- vMath.mounted!(container, {} as any);
55
+ directive.mounted!(container, {} as any, {} as any, {} as any);
55
56
 
56
57
  await vi.waitFor(() => {
57
58
  expect(mathToHtml).toHaveBeenCalledWith('alpha', 'asciimath', true);
@@ -60,7 +61,7 @@ describe('v-math directive', () => {
60
61
 
61
62
  it('skips elements without data-expr', async () => {
62
63
  container.innerHTML = '<span class="math-pending">no expr</span>';
63
- vMath.mounted!(container, {} as any);
64
+ directive.mounted!(container, {} as any, {} as any, {} as any);
64
65
 
65
66
  await vi.waitFor(() => {
66
67
  expect(mathToHtml).not.toHaveBeenCalled();
@@ -69,7 +70,7 @@ describe('v-math directive', () => {
69
70
 
70
71
  it('uses default format asciimath when data-format is missing', async () => {
71
72
  container.innerHTML = '<span class="math-pending" data-expr="x">x</span>';
72
- vMath.mounted!(container, {} as any);
73
+ directive.mounted!(container, {} as any, {} as any, {} as any);
73
74
 
74
75
  await vi.waitFor(() => {
75
76
  expect(mathToHtml).toHaveBeenCalledWith('x', 'asciimath', false);