@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
@@ -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
+ });
@@ -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,171 @@
1
+ import { createPinia, setActivePinia } from 'pinia';
2
+ import { createRouter, createMemoryHistory } from 'vue-router';
3
+ import { LocalizedConcept } from 'glossarist';
4
+ import type { Manifest, ConceptSummary, SearchHit } from '../adapters/types';
5
+
6
+ // ── Manifest Factory ──────────────────────────────────────────────────
7
+
8
+ const STUB_COMPONENT = { template: '<div/>' };
9
+
10
+ export function makeManifest(overrides: Partial<Manifest> = {}): 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
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ // ── Adapter Stub ──────────────────────────────────────────────────────
36
+
37
+ export interface AdapterStubOptions {
38
+ concepts?: ConceptSummary[];
39
+ search?: () => SearchHit[];
40
+ fetchConcept?: () => Promise<any>;
41
+ getAdjacentConcepts?: () => { prev: string | null; next: string | null };
42
+ getConceptPosition?: () => number;
43
+ isRangeLoaded?: () => boolean;
44
+ ensureChunksForRange?: () => Promise<void>;
45
+ ensureAllChunksLoaded?: () => Promise<void>;
46
+ extractEdges?: () => any[];
47
+ extractDomainEdges?: () => any[];
48
+ getIndexEntry?: () => any;
49
+ }
50
+
51
+ export function makeAdapterStub(options: AdapterStubOptions = {}): any {
52
+ return {
53
+ index: options.concepts ?? [],
54
+ manifest: null,
55
+ registerId: 'test',
56
+ getConceptCount: () => (options.concepts ?? []).length,
57
+ getConcepts: () => options.concepts ?? [],
58
+ search: options.search ?? (() => []),
59
+ fetchConcept: options.fetchConcept ?? (() => Promise.resolve(null)),
60
+ getAdjacentConcepts: options.getAdjacentConcepts ?? (() => ({ prev: null, next: null })),
61
+ getConceptPosition: options.getConceptPosition ?? (() => -1),
62
+ isRangeLoaded: options.isRangeLoaded ?? (() => true),
63
+ ensureChunksForRange: options.ensureChunksForRange ?? (() => Promise.resolve()),
64
+ ensureAllChunksLoaded: options.ensureAllChunksLoaded ?? (() => Promise.resolve()),
65
+ extractEdges: options.extractEdges ?? (() => []),
66
+ extractDomainEdges: options.extractDomainEdges ?? (() => []),
67
+ getIndexEntry: options.getIndexEntry ?? (() => null),
68
+ };
69
+ }
70
+
71
+ // ── Concept Data Factories ────────────────────────────────────────────
72
+
73
+ export function makeLocalizedConcept(overrides: Record<string, unknown> = {}): LocalizedConcept {
74
+ return LocalizedConcept.fromJSON({
75
+ language_code: 'eng',
76
+ entry_status: 'valid',
77
+ terms: [{ type: 'expression', designation: 'test term' }],
78
+ ...overrides,
79
+ });
80
+ }
81
+
82
+ export function makeConceptSummary(overrides: Partial<ConceptSummary> = {}): ConceptSummary {
83
+ return {
84
+ id: '1',
85
+ designations: { eng: 'test concept' },
86
+ eng: 'test concept',
87
+ status: 'valid',
88
+ ...overrides,
89
+ };
90
+ }
91
+
92
+ export function makeSearchHit(overrides: Partial<SearchHit> = {}): SearchHit {
93
+ return {
94
+ conceptId: '1',
95
+ registerId: 'test',
96
+ designation: 'test',
97
+ language: 'eng',
98
+ matchField: 'designation',
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ // ── Router Factory ────────────────────────────────────────────────────
104
+
105
+ export type RouteSet = 'minimal' | 'search' | 'dataset' | 'full' | 'resolve' | 'news' | 'pages' | 'contributors' | 'graph';
106
+
107
+ const ROUTE_SETS: Record<RouteSet, any[]> = {
108
+ minimal: [{ path: '/', name: 'home', component: STUB_COMPONENT }],
109
+ search: [
110
+ { path: '/', name: 'home', component: STUB_COMPONENT },
111
+ { path: '/search', name: 'search', component: STUB_COMPONENT },
112
+ ],
113
+ dataset: [
114
+ { path: '/', name: 'home', component: STUB_COMPONENT },
115
+ { path: '/dataset/:registerId', name: 'dataset', component: STUB_COMPONENT },
116
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: STUB_COMPONENT },
117
+ { path: '/dataset/:registerId/stats', name: 'stats', component: STUB_COMPONENT },
118
+ { path: '/dataset/:registerId/about', name: 'about', component: STUB_COMPONENT },
119
+ ],
120
+ full: [
121
+ { path: '/', name: 'home', component: STUB_COMPONENT },
122
+ { path: '/dataset/:registerId', name: 'dataset', component: STUB_COMPONENT },
123
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: STUB_COMPONENT },
124
+ { path: '/dataset/:registerId/stats', name: 'stats', component: STUB_COMPONENT },
125
+ { path: '/dataset/:registerId/about', name: 'about', component: STUB_COMPONENT },
126
+ { path: '/search', name: 'search', component: STUB_COMPONENT },
127
+ { path: '/graph', name: 'graph', component: STUB_COMPONENT },
128
+ ],
129
+ resolve: [
130
+ { path: '/', name: 'home', component: STUB_COMPONENT },
131
+ { path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: STUB_COMPONENT },
132
+ { path: '/resolve/:uri(.*)', name: 'resolve', component: STUB_COMPONENT },
133
+ ],
134
+ news: [
135
+ { path: '/', name: 'home', component: STUB_COMPONENT },
136
+ { path: '/news', name: 'news', component: STUB_COMPONENT },
137
+ ],
138
+ pages: [
139
+ { path: '/', name: 'home', component: STUB_COMPONENT },
140
+ { path: '/pages/:slug', name: 'page', component: STUB_COMPONENT },
141
+ ],
142
+ contributors: [
143
+ { path: '/', name: 'home', component: STUB_COMPONENT },
144
+ { path: '/contributors', name: 'contributors', component: STUB_COMPONENT },
145
+ ],
146
+ graph: [
147
+ { path: '/', name: 'home', component: STUB_COMPONENT },
148
+ { path: '/graph', name: 'graph', component: STUB_COMPONENT },
149
+ ],
150
+ };
151
+
152
+ export async function createTestRouter(
153
+ routeSet: RouteSet = 'minimal',
154
+ initialPath = '/',
155
+ ) {
156
+ const router = createRouter({
157
+ history: createMemoryHistory(),
158
+ routes: ROUTE_SETS[routeSet],
159
+ });
160
+ router.push(initialPath);
161
+ await router.isReady();
162
+ return router;
163
+ }
164
+
165
+ // ── Pinia Setup ───────────────────────────────────────────────────────
166
+
167
+ export function setupPinia() {
168
+ const pinia = createPinia();
169
+ setActivePinia(pinia);
170
+ return pinia;
171
+ }
@@ -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
+ });