@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.
- package/README.md +3 -2
- package/cli/index.mjs +2 -1
- package/env.d.ts +5 -0
- package/package.json +4 -3
- package/scripts/build-edges.js +78 -10
- package/scripts/generate-data.mjs +152 -20
- package/scripts/generate-ontology-data.mjs +184 -0
- package/scripts/generate-ontology-schema.mjs +315 -0
- 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 +1 -1
- package/src/__tests__/concept-card.test.ts +115 -0
- package/src/__tests__/concept-detail-interaction.test.ts +273 -0
- package/src/__tests__/concept-formats.test.ts +32 -30
- package/src/__tests__/concept-timeline.test.ts +200 -0
- package/src/__tests__/concept-view.test.ts +88 -0
- package/src/__tests__/contributors-view.test.ts +103 -0
- package/src/__tests__/dataset-adapter.test.ts +172 -23
- package/src/__tests__/dataset-view.test.ts +232 -0
- package/src/__tests__/designation-registry.test.ts +161 -0
- package/src/__tests__/format-downloads.test.ts +98 -0
- package/src/__tests__/graph-view.test.ts +69 -0
- package/src/__tests__/graph.test.ts +62 -0
- package/src/__tests__/home-interaction.test.ts +157 -0
- package/src/__tests__/language-detail.test.ts +203 -0
- package/src/__tests__/nav-icon.test.ts +48 -0
- package/src/__tests__/news-view.test.ts +87 -0
- package/src/__tests__/ontology-registry.test.ts +109 -0
- package/src/__tests__/page-view.test.ts +83 -0
- package/src/__tests__/relationship-categories.test.ts +62 -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 +171 -0
- package/src/__tests__/ui-store.test.ts +100 -0
- package/src/__tests__/v-math.test.ts +8 -7
- package/src/adapters/DatasetAdapter.ts +188 -63
- package/src/adapters/model-bridge.ts +277 -0
- package/src/adapters/ontology-registry.ts +75 -0
- package/src/adapters/ontology-schema.ts +100 -0
- package/src/adapters/types.ts +53 -78
- package/src/components/AppSidebar.vue +1 -1
- package/src/components/CitationDisplay.vue +35 -0
- package/src/components/ConceptDetail.vue +349 -146
- package/src/components/ConceptRdfView.vue +397 -0
- package/src/components/ConceptTimeline.vue +57 -60
- package/src/components/GraphPanel.vue +96 -31
- package/src/components/LanguageDetail.vue +46 -61
- package/src/components/NavIcon.vue +1 -0
- package/src/components/NonVerbalRepDisplay.vue +38 -0
- package/src/components/RelationshipList.vue +99 -0
- package/src/composables/use-render-options.ts +1 -4
- package/src/config/use-site-config.ts +3 -0
- package/src/data/ontology-schema.json +1551 -0
- package/src/data/taxonomies.json +543 -0
- package/src/graph/GraphEngine.ts +7 -4
- package/src/router/index.ts +6 -1
- package/src/shims/empty.ts +1 -0
- package/src/shims/node-crypto.ts +6 -0
- package/src/shims/node-path.ts +10 -0
- package/src/stores/vocabulary.ts +82 -32
- package/src/style.css +74 -20
- package/src/utils/asciidoc-lite.ts +17 -19
- package/src/utils/concept-formats.ts +22 -20
- package/src/utils/concept-helpers.ts +54 -0
- package/src/utils/designation-registry.ts +124 -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/utils/relationship-categories.ts +84 -0
- package/src/views/ConceptView.vue +22 -1
- package/src/views/DatasetView.vue +7 -2
- package/src/views/OntologySchemaView.vue +302 -0
- package/src/views/PageView.vue +28 -17
- package/src/views/StatsView.vue +34 -12
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Parse the glossarist OWL ontology (TTL) into a structured JSON schema
|
|
4
|
+
* for the Ontospy-style browser view.
|
|
5
|
+
*
|
|
6
|
+
* Reads: ../concept-model/ontologies/glossarist.ttl
|
|
7
|
+
* Writes: src/data/ontology-schema.json
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
11
|
+
import { resolve, dirname, join } from 'path';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const ROOT = resolve(__dirname, '..');
|
|
16
|
+
const ONTOLOGY_TTL = resolve(ROOT, '..', 'concept-model', 'ontologies', 'glossarist.ttl');
|
|
17
|
+
const OUTPUT = resolve(ROOT, 'src', 'data', 'ontology-schema.json');
|
|
18
|
+
|
|
19
|
+
const KNOWN_PREFIXES = {
|
|
20
|
+
gloss: 'https://www.glossarist.org/ontologies/',
|
|
21
|
+
owl: 'http://www.w3.org/2002/07/owl#',
|
|
22
|
+
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
23
|
+
rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
|
|
24
|
+
skos: 'http://www.w3.org/2004/02/skos/core#',
|
|
25
|
+
xl: 'http://www.w3.org/2008/05/skos-xl#',
|
|
26
|
+
'iso-thes': 'http://purl.org/iso25964/skos-thes#',
|
|
27
|
+
dcterms: 'http://purl.org/dc/terms/',
|
|
28
|
+
prov: 'http://www.w3.org/ns/prov#',
|
|
29
|
+
xsd: 'http://www.w3.org/2001/XMLSchema#',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function expandPrefixed(term) {
|
|
33
|
+
for (const [prefix, uri] of Object.entries(KNOWN_PREFIXES)) {
|
|
34
|
+
if (term.startsWith(prefix + ':')) {
|
|
35
|
+
return uri + term.slice(prefix.length + 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return term;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function compactIri(iri) {
|
|
42
|
+
for (const [prefix, uri] of Object.entries(KNOWN_PREFIXES)) {
|
|
43
|
+
if (iri.startsWith(uri)) {
|
|
44
|
+
return prefix + ':' + iri.slice(uri.length);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return iri;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Minimal TTL subject-block splitter. Handles nested [] and () and quoted strings.
|
|
52
|
+
*/
|
|
53
|
+
function splitSubjectBlocks(text) {
|
|
54
|
+
const blocks = [];
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let start = -1;
|
|
57
|
+
let inTripleQuote = false;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < text.length; i++) {
|
|
60
|
+
const ch = text[i];
|
|
61
|
+
|
|
62
|
+
if (inTripleQuote) {
|
|
63
|
+
if (ch === '"' && text.slice(i, i + 3) === '"""') {
|
|
64
|
+
inTripleQuote = false;
|
|
65
|
+
i += 2;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ch === '"' && text.slice(i, i + 3) === '"""') {
|
|
71
|
+
inTripleQuote = true;
|
|
72
|
+
i += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ch === '"') {
|
|
77
|
+
i++;
|
|
78
|
+
while (i < text.length && text[i] !== '"') {
|
|
79
|
+
if (text[i] === '\\') i++;
|
|
80
|
+
i++;
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (ch === '[' || ch === '(') depth++;
|
|
86
|
+
if (ch === ']' || ch === ')') depth--;
|
|
87
|
+
|
|
88
|
+
if (depth === 0 && ch === '.') {
|
|
89
|
+
if (start >= 0) {
|
|
90
|
+
blocks.push(text.slice(start, i));
|
|
91
|
+
start = -1;
|
|
92
|
+
}
|
|
93
|
+
} else if (start < 0 && /\S/.test(ch)) {
|
|
94
|
+
start = i;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return blocks;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractLiteral(block, predicate) {
|
|
102
|
+
const tripleQuoted = new RegExp(predicate + '\\s+"""([^]*?)"""@en');
|
|
103
|
+
let m = block.match(tripleQuoted);
|
|
104
|
+
if (m) return m[1].replace(/\s+/g, ' ').trim();
|
|
105
|
+
|
|
106
|
+
const singleQuoted = new RegExp(predicate + '\\s+"([^"]*?)"@en');
|
|
107
|
+
m = block.match(singleQuoted);
|
|
108
|
+
if (m) return m[1];
|
|
109
|
+
|
|
110
|
+
// Without @en
|
|
111
|
+
const plain = new RegExp(predicate + '\\s+"""([^]*?)"""');
|
|
112
|
+
m = block.match(plain);
|
|
113
|
+
if (m) return m[1].replace(/\s+/g, ' ').trim();
|
|
114
|
+
|
|
115
|
+
const plainSingle = new RegExp(predicate + '\\s+"([^"]*?)"');
|
|
116
|
+
m = block.match(plainSingle);
|
|
117
|
+
return m ? m[1] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractResource(block, predicate) {
|
|
121
|
+
const re = new RegExp(predicate + '\\s+([^\\s,;]+)');
|
|
122
|
+
const m = block.match(re);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
let val = m[1].replace(/[;.]+$/, '');
|
|
125
|
+
if (val === 'a') return null;
|
|
126
|
+
return val;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function extractAllResources(block, predicate) {
|
|
130
|
+
const results = [];
|
|
131
|
+
const re = new RegExp(predicate + '\\s+', 'g');
|
|
132
|
+
let match;
|
|
133
|
+
while ((match = re.exec(block)) !== null) {
|
|
134
|
+
const rest = block.slice(match.index + match[0].length).trimStart();
|
|
135
|
+
// Read comma-separated resources until ; or .
|
|
136
|
+
const tokens = rest.split(/[\s;.\n]+/)[0];
|
|
137
|
+
if (tokens && tokens !== 'a') {
|
|
138
|
+
results.push(tokens.replace(/[;,]+$/, ''));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [...new Set(results)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseOntology(ttlText) {
|
|
145
|
+
const rawLines = ttlText.split('\n');
|
|
146
|
+
// Remove comment lines but keep content
|
|
147
|
+
const cleaned = rawLines.map(l => l.replace(/#[^\n]*/g, '')).join('\n');
|
|
148
|
+
|
|
149
|
+
const blocks = splitSubjectBlocks(cleaned);
|
|
150
|
+
|
|
151
|
+
const classes = [];
|
|
152
|
+
const properties = [];
|
|
153
|
+
|
|
154
|
+
for (const block of blocks) {
|
|
155
|
+
const trimmed = block.trim();
|
|
156
|
+
if (!trimmed) continue;
|
|
157
|
+
|
|
158
|
+
// Parse subject
|
|
159
|
+
const subjectMatch = trimmed.match(/^([^\s]+)/);
|
|
160
|
+
if (!subjectMatch) continue;
|
|
161
|
+
const subject = subjectMatch[1];
|
|
162
|
+
|
|
163
|
+
// Skip ontology declaration, prefix declarations
|
|
164
|
+
if (subject === '@prefix' || subject.startsWith('@')) continue;
|
|
165
|
+
if (subject.includes('glossarist>') && !subject.startsWith('gloss:')) continue;
|
|
166
|
+
|
|
167
|
+
// Determine type
|
|
168
|
+
const typeMatch = trimmed.match(/\ba\s+(.+?)(?:\s*[;.\n]|$)/);
|
|
169
|
+
if (!typeMatch) continue;
|
|
170
|
+
const typeStr = typeMatch[1];
|
|
171
|
+
|
|
172
|
+
const isClass = /\bowl:Class\b/.test(typeStr);
|
|
173
|
+
const isObjectProperty = /\bowl:ObjectProperty\b/.test(typeStr);
|
|
174
|
+
const isDatatypeProperty = /\bowl:DatatypeProperty\b/.test(typeStr);
|
|
175
|
+
|
|
176
|
+
if (!isClass && !isObjectProperty && !isDatatypeProperty) continue;
|
|
177
|
+
|
|
178
|
+
const label = extractLiteral(trimmed, 'rdfs:label');
|
|
179
|
+
const comment = extractLiteral(trimmed, 'rdfs:comment');
|
|
180
|
+
const iri = expandPrefixed(subject);
|
|
181
|
+
const compact = compactIri(iri);
|
|
182
|
+
|
|
183
|
+
if (isClass) {
|
|
184
|
+
const subClassOf = extractResource(trimmed, 'rdfs:subClassOf');
|
|
185
|
+
const disjointWith = extractResource(trimmed, 'owl:disjointWith');
|
|
186
|
+
|
|
187
|
+
classes.push({
|
|
188
|
+
iri,
|
|
189
|
+
compact,
|
|
190
|
+
label: label || subject.replace('gloss:', ''),
|
|
191
|
+
comment,
|
|
192
|
+
subClassOf: subClassOf ? compactIri(expandPrefixed(subClassOf)) : null,
|
|
193
|
+
disjointWith: disjointWith ? compactIri(expandPrefixed(disjointWith)) : null,
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
const domain = extractResource(trimmed, 'rdfs:domain');
|
|
197
|
+
const range = extractResource(trimmed, 'rdfs:range');
|
|
198
|
+
const inverseOf = extractResource(trimmed, 'owl:inverseOf');
|
|
199
|
+
|
|
200
|
+
// For unionOf domains/ranges, detect the bracket pattern
|
|
201
|
+
// rdfs:domain [ a owl:Class ; owl:unionOf ( gloss:A gloss:B ) ] ;
|
|
202
|
+
let domainUnion = null;
|
|
203
|
+
let rangeUnion = null;
|
|
204
|
+
|
|
205
|
+
const unionDomainMatch = trimmed.match(/rdfs:domain\s+\[\s*a\s+owl:Class\s*;\s*owl:unionOf\s*\(([^)]+)\)\s*\]/);
|
|
206
|
+
if (unionDomainMatch) {
|
|
207
|
+
domainUnion = unionDomainMatch[1].trim().split(/\s+/).map(t => compactIri(expandPrefixed(t)));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const unionRangeMatch = trimmed.match(/rdfs:range\s+\[\s*a\s+owl:Class\s*;\s*owl:unionOf\s*\(([^)]+)\)\s*\]/);
|
|
211
|
+
if (unionRangeMatch) {
|
|
212
|
+
rangeUnion = unionRangeMatch[1].trim().split(/\s+/).map(t => compactIri(expandPrefixed(t)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
properties.push({
|
|
216
|
+
iri,
|
|
217
|
+
compact,
|
|
218
|
+
label: label || subject.replace('gloss:', ''),
|
|
219
|
+
comment,
|
|
220
|
+
type: isObjectProperty ? 'object' : 'datatype',
|
|
221
|
+
domain: domain ? compactIri(expandPrefixed(domain)) : null,
|
|
222
|
+
domainUnion: domainUnion,
|
|
223
|
+
range: range ? compactIri(expandPrefixed(range)) : null,
|
|
224
|
+
rangeUnion: rangeUnion,
|
|
225
|
+
inverseOf: inverseOf ? compactIri(expandPrefixed(inverseOf)) : null,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { classes, properties };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildClassHierarchy(classes) {
|
|
234
|
+
const map = new Map();
|
|
235
|
+
for (const c of classes) {
|
|
236
|
+
map.set(c.compact, c);
|
|
237
|
+
c.children = [];
|
|
238
|
+
c.ancestors = [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Build children
|
|
242
|
+
for (const c of classes) {
|
|
243
|
+
if (c.subClassOf && map.has(c.subClassOf)) {
|
|
244
|
+
map.get(c.subClassOf).children.push(c.compact);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Build ancestor chains
|
|
249
|
+
for (const c of classes) {
|
|
250
|
+
const chain = [];
|
|
251
|
+
let current = c.subClassOf;
|
|
252
|
+
while (current && map.has(current)) {
|
|
253
|
+
chain.push(current);
|
|
254
|
+
current = map.get(current).subClassOf;
|
|
255
|
+
}
|
|
256
|
+
// Add non-glossarist ancestors
|
|
257
|
+
if (current) chain.push(current);
|
|
258
|
+
c.ancestors = chain;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Find roots (no subClassOf or subClassOf points outside our ontology)
|
|
262
|
+
const roots = classes
|
|
263
|
+
.filter(c => !c.subClassOf || !map.has(c.subClassOf))
|
|
264
|
+
.map(c => c.compact);
|
|
265
|
+
|
|
266
|
+
return { roots, map: Object.fromEntries(map) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function groupPropertiesByDomain(properties) {
|
|
270
|
+
const groups = {};
|
|
271
|
+
for (const p of properties) {
|
|
272
|
+
const domains = p.domainUnion || (p.domain ? [p.domain] : ['(unspecified)']);
|
|
273
|
+
for (const d of domains) {
|
|
274
|
+
if (!groups[d]) groups[d] = { object: [], datatype: [] };
|
|
275
|
+
groups[d][p.type].push(p.compact);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return groups;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function main() {
|
|
282
|
+
if (!existsSync(ONTOLOGY_TTL)) {
|
|
283
|
+
console.error(`Ontology file not found: ${ONTOLOGY_TTL}`);
|
|
284
|
+
console.error('Ensure concept-model is available at ../concept-model/');
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const ttlText = readFileSync(ONTOLOGY_TTL, 'utf-8');
|
|
289
|
+
const { classes, properties } = parseOntology(ttlText);
|
|
290
|
+
|
|
291
|
+
const hierarchy = buildClassHierarchy(classes);
|
|
292
|
+
const propsByDomain = groupPropertiesByDomain(properties);
|
|
293
|
+
|
|
294
|
+
const output = {
|
|
295
|
+
ontologyIri: 'https://www.glossarist.org/ontologies/glossarist',
|
|
296
|
+
ontologyLabel: 'Glossarist Ontology',
|
|
297
|
+
classes: hierarchy.map,
|
|
298
|
+
classHierarchyRoots: hierarchy.roots,
|
|
299
|
+
properties: Object.fromEntries(properties.map(p => [p.compact, p])),
|
|
300
|
+
propertiesByDomain: propsByDomain,
|
|
301
|
+
stats: {
|
|
302
|
+
classCount: classes.length,
|
|
303
|
+
objectPropertyCount: properties.filter(p => p.type === 'object').length,
|
|
304
|
+
datatypePropertyCount: properties.filter(p => p.type === 'datatype').length,
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
mkdirSync(dirname(OUTPUT), { recursive: true });
|
|
309
|
+
writeFileSync(OUTPUT, JSON.stringify(output, null, 2) + '\n');
|
|
310
|
+
|
|
311
|
+
console.log(`Parsed ${output.stats.classCount} classes, ${output.stats.objectPropertyCount} object properties, ${output.stats.datatypePropertyCount} datatype properties`);
|
|
312
|
+
console.log(`Wrote ${OUTPUT}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
main();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mount, flushPromises } from '@vue/test-utils';
|
|
3
|
+
import AboutView from '../views/AboutView.vue';
|
|
4
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
|
+
import { createTestRouter, setupPinia, makeManifest, makeAdapterStub } from './test-helpers';
|
|
6
|
+
|
|
7
|
+
const testManifest = makeManifest({
|
|
8
|
+
title: 'Test Dataset',
|
|
9
|
+
description: 'A test dataset for terminology',
|
|
10
|
+
languages: ['eng', 'fra'],
|
|
11
|
+
conceptCount: 100,
|
|
12
|
+
tags: ['terminology', 'iso'],
|
|
13
|
+
sourceRepo: 'https://github.com/glossarist/test-dataset',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('AboutView', () => {
|
|
17
|
+
let pinia: ReturnType<typeof setupPinia>;
|
|
18
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
pinia = setupPinia();
|
|
22
|
+
router = await createTestRouter('dataset', '/dataset/test/about');
|
|
23
|
+
const store = useVocabularyStore();
|
|
24
|
+
store.manifests.set('test', testManifest);
|
|
25
|
+
store.datasets.set('test', makeAdapterStub());
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function mountAbout() {
|
|
29
|
+
return mount(AboutView, {
|
|
30
|
+
global: { plugins: [pinia, router] },
|
|
31
|
+
props: { registerId: 'test' },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('renders About heading', async () => {
|
|
36
|
+
const wrapper = mountAbout();
|
|
37
|
+
await flushPromises();
|
|
38
|
+
expect(wrapper.text()).toContain('About');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('shows description', async () => {
|
|
42
|
+
const wrapper = mountAbout();
|
|
43
|
+
await flushPromises();
|
|
44
|
+
expect(wrapper.text()).toContain('A test dataset for terminology');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('shows owner', async () => {
|
|
48
|
+
const wrapper = mountAbout();
|
|
49
|
+
await flushPromises();
|
|
50
|
+
expect(wrapper.text()).toContain('ISO');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('shows concept count', async () => {
|
|
54
|
+
const wrapper = mountAbout();
|
|
55
|
+
await flushPromises();
|
|
56
|
+
expect(wrapper.text()).toContain('100');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('shows language count', async () => {
|
|
60
|
+
const wrapper = mountAbout();
|
|
61
|
+
await flushPromises();
|
|
62
|
+
expect(wrapper.text()).toContain('English');
|
|
63
|
+
expect(wrapper.text()).toContain('French');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('shows last updated date', async () => {
|
|
67
|
+
const wrapper = mountAbout();
|
|
68
|
+
await flushPromises();
|
|
69
|
+
expect(wrapper.text()).toContain('2025-01-01');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('shows source repo link', async () => {
|
|
73
|
+
const wrapper = mountAbout();
|
|
74
|
+
await flushPromises();
|
|
75
|
+
expect(wrapper.text()).toContain('glossarist/test-dataset');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('shows tags', async () => {
|
|
79
|
+
const wrapper = mountAbout();
|
|
80
|
+
await flushPromises();
|
|
81
|
+
expect(wrapper.text()).toContain('terminology');
|
|
82
|
+
expect(wrapper.text()).toContain('iso');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('renders breadcrumb navigation', async () => {
|
|
86
|
+
const wrapper = mountAbout();
|
|
87
|
+
await flushPromises();
|
|
88
|
+
expect(wrapper.text()).toContain('Home');
|
|
89
|
+
expect(wrapper.text()).toContain('Test Dataset');
|
|
90
|
+
expect(wrapper.text()).toContain('About');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('shows schema version', async () => {
|
|
94
|
+
const wrapper = mountAbout();
|
|
95
|
+
await flushPromises();
|
|
96
|
+
expect(wrapper.text()).toContain('1.0');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import AppFooter from '../components/AppFooter.vue';
|
|
4
|
+
import { createTestRouter, setupPinia } from './test-helpers';
|
|
5
|
+
|
|
6
|
+
describe('AppFooter', () => {
|
|
7
|
+
let pinia: ReturnType<typeof setupPinia>;
|
|
8
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
pinia = setupPinia();
|
|
12
|
+
router = await createTestRouter('minimal', '/');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function mountFooter() {
|
|
16
|
+
return mount(AppFooter, {
|
|
17
|
+
global: { plugins: [pinia, router] },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it('renders powered by text', () => {
|
|
22
|
+
const wrapper = mountFooter();
|
|
23
|
+
expect(wrapper.text()).toContain('Built with the');
|
|
24
|
+
expect(wrapper.text()).toContain('Glossarist Concept Browser');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('omits copyright when no config loaded', () => {
|
|
28
|
+
const wrapper = mountFooter();
|
|
29
|
+
expect(wrapper.text()).not.toContain('©');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('links to GitHub repository', () => {
|
|
33
|
+
const wrapper = mountFooter();
|
|
34
|
+
const link = wrapper.findAll('a').find(a => a.text().includes('Glossarist Concept Browser'));
|
|
35
|
+
expect(link).toBeDefined();
|
|
36
|
+
expect(link!.attributes('href')).toContain('github.com');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
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 AppHeader from '../components/AppHeader.vue';
|
|
6
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
+
import type { Manifest } from '../adapters/types';
|
|
8
|
+
|
|
9
|
+
function makeManifest(): 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
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function createTestRouter() {
|
|
34
|
+
return createRouter({
|
|
35
|
+
history: createMemoryHistory(),
|
|
36
|
+
routes: [
|
|
37
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
38
|
+
{ path: '/search', name: 'search', component: { template: '<div/>' } },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('AppHeader', () => {
|
|
44
|
+
let pinia: ReturnType<typeof createPinia>;
|
|
45
|
+
let router: Awaited<ReturnType<typeof createTestRouter>>;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
pinia = createPinia();
|
|
49
|
+
setActivePinia(pinia);
|
|
50
|
+
router = await createTestRouter();
|
|
51
|
+
router.push('/');
|
|
52
|
+
await router.isReady();
|
|
53
|
+
const store = useVocabularyStore();
|
|
54
|
+
store.manifests.set('test', makeManifest());
|
|
55
|
+
store.datasets.set('test', { index: [], getConceptCount: () => 0, getConcepts: () => [] } as any);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function mountHeader() {
|
|
59
|
+
return mount(AppHeader, {
|
|
60
|
+
global: { plugins: [pinia, router] },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('renders logo/title text', () => {
|
|
65
|
+
const wrapper = mountHeader();
|
|
66
|
+
expect(wrapper.text()).toContain('Glossarist');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('renders search input', () => {
|
|
70
|
+
const wrapper = mountHeader();
|
|
71
|
+
const input = wrapper.find('input[aria-label="Search concepts"]');
|
|
72
|
+
expect(input.exists()).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders mobile hamburger button', () => {
|
|
76
|
+
const wrapper = mountHeader();
|
|
77
|
+
const hamburger = wrapper.find('button[aria-label="Open navigation menu"]');
|
|
78
|
+
expect(hamburger.exists()).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('navigates home on logo click', async () => {
|
|
82
|
+
const wrapper = mountHeader();
|
|
83
|
+
const logoBtn = wrapper.findAll('button').find(b => b.text().includes('Glossarist'));
|
|
84
|
+
expect(logoBtn).toBeDefined();
|
|
85
|
+
await logoBtn!.trigger('click');
|
|
86
|
+
await flushPromises();
|
|
87
|
+
expect(router.currentRoute.value.name).toBe('home');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('navigates to search on form submit with query', async () => {
|
|
91
|
+
const wrapper = mountHeader();
|
|
92
|
+
const input = wrapper.find('input[aria-label="Search concepts"]');
|
|
93
|
+
await input.setValue('road');
|
|
94
|
+
const form = wrapper.find('form');
|
|
95
|
+
await form.trigger('submit');
|
|
96
|
+
await flushPromises();
|
|
97
|
+
expect(router.currentRoute.value.name).toBe('search');
|
|
98
|
+
expect(router.currentRoute.value.query.q).toBe('road');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not navigate on empty search', async () => {
|
|
102
|
+
const wrapper = mountHeader();
|
|
103
|
+
const form = wrapper.find('form');
|
|
104
|
+
await form.trigger('submit');
|
|
105
|
+
await flushPromises();
|
|
106
|
+
expect(router.currentRoute.value.name).toBe('home');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('renders theme toggle button', () => {
|
|
110
|
+
const wrapper = mountHeader();
|
|
111
|
+
const themeBtn = wrapper.findAll('button').find(b =>
|
|
112
|
+
b.attributes('aria-label')?.includes('Switch to')
|
|
113
|
+
);
|
|
114
|
+
expect(themeBtn).toBeDefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('toggles theme on button click', async () => {
|
|
118
|
+
const wrapper = mountHeader();
|
|
119
|
+
const themeBtn = wrapper.findAll('button').find(b =>
|
|
120
|
+
b.attributes('aria-label')?.includes('Switch to')
|
|
121
|
+
);
|
|
122
|
+
await themeBtn!.trigger('click');
|
|
123
|
+
expect(wrapper.html()).toContain('M12 3v1m0 16v1m9-9h-1');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('shows dataset count', () => {
|
|
127
|
+
const wrapper = mountHeader();
|
|
128
|
+
expect(wrapper.text()).toContain('1 datasets');
|
|
129
|
+
});
|
|
130
|
+
});
|