@glossarist/concept-browser 0.7.99 → 0.7.101

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.99",
3
+ "version": "0.7.101",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import HyperedgeDiagram from '../../components/HyperedgeDiagram.vue';
5
+
6
+ /**
7
+ * HyperedgeDiagram mounts a small SVG and calls the actual sphere
8
+ * renderer (drawRakeBundles or drawGenericPipes) on mount. These
9
+ * tests pin the contract: each kind produces the right kind of SVG
10
+ * child elements, matching what the sphere itself renders.
11
+ */
12
+ describe('HyperedgeDiagram', () => {
13
+ beforeEach(() => {
14
+ setActivePinia(createPinia());
15
+ });
16
+
17
+ it('renders an SVG element', () => {
18
+ const w = mount(HyperedgeDiagram, { props: { kind: 'partitive' } });
19
+ expect(w.find('svg.hyperedge-diagram').exists()).toBe(true);
20
+ });
21
+
22
+ it('partitive kind calls drawRakeBundles (emits line.rake-seg + circle.rake-junction)', () => {
23
+ const w = mount(HyperedgeDiagram, { props: { kind: 'partitive' } });
24
+ const svg = w.element as SVGSVGElement;
25
+ /* The sample fixture has 3 members with mixed multiplicity, so the
26
+ rake emits ≥1 segment + exactly 1 junction circle + 1 diamond. */
27
+ expect(svg.querySelectorAll('line.rake-seg').length).toBeGreaterThan(0);
28
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(1);
29
+ expect(svg.querySelectorAll('path').length).toBe(1);
30
+ });
31
+
32
+ it('generic kind calls drawGenericPipes (emits line.pipe-seg + rect.pipe-junction)', () => {
33
+ const w = mount(HyperedgeDiagram, { props: { kind: 'generic' } });
34
+ const svg = w.element as SVGSVGElement;
35
+ /* The sample fixture has 3 members → 1 thick pipe + 3 thin threads = 4 segments,
36
+ plus 1 square junction and 1 diamond. */
37
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(4);
38
+ expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(1);
39
+ expect(svg.querySelectorAll('path').length).toBe(1);
40
+ });
41
+
42
+ it('partitive kind does not leak pipe-and-thread artifacts', () => {
43
+ const w = mount(HyperedgeDiagram, { props: { kind: 'partitive' } });
44
+ const svg = w.element as SVGSVGElement;
45
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
46
+ expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(0);
47
+ });
48
+
49
+ it('generic kind does not leak rake artifacts', () => {
50
+ const w = mount(HyperedgeDiagram, { props: { kind: 'generic' } });
51
+ const svg = w.element as SVGSVGElement;
52
+ expect(svg.querySelectorAll('line.rake-seg').length).toBe(0);
53
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
54
+ });
55
+ });
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import LearnCard from '../../../components/learn/LearnCard.vue';
5
+ import DimensionCard from '../../../components/learn/DimensionCard.vue';
6
+
7
+ describe('LearnCard', () => {
8
+ beforeEach(() => setActivePinia(createPinia()));
9
+
10
+ it('renders the title and ISO reference', () => {
11
+ const w = mount(LearnCard, {
12
+ props: { title: 'partitive relation', isoReference: 'ISO 704:2022 §5.5.4.3' },
13
+ });
14
+ expect(w.find('h3').text()).toBe('partitive relation');
15
+ expect(w.find('.font-mono').text()).toBe('ISO 704:2022 §5.5.4.3');
16
+ });
17
+
18
+ it('renders the body slot', () => {
19
+ const w = mount(LearnCard, {
20
+ props: { title: 'x' },
21
+ slots: { default: '<p>Body content</p>' },
22
+ });
23
+ expect(w.find('.learn-card-body').html()).toContain('Body content');
24
+ });
25
+
26
+ it('renders the example slot in its own section', () => {
27
+ const w = mount(LearnCard, {
28
+ props: { title: 'x' },
29
+ slots: { example: 'VIM 1.9 measurement unit' },
30
+ });
31
+ expect(w.find('.learn-card-example').text()).toContain('VIM 1.9');
32
+ });
33
+
34
+ it('omits the "learn more" footer when learnMoreUrl is absent', () => {
35
+ const w = mount(LearnCard, { props: { title: 'x' } });
36
+ expect(w.find('.learn-card-footer').exists()).toBe(false);
37
+ });
38
+
39
+ it('renders the learn-more footer when learnMoreUrl is present', () => {
40
+ const w = mount(LearnCard, {
41
+ props: { title: 'x', learnMoreUrl: 'https://www.glossarist.org/model/x' },
42
+ });
43
+ const link = w.find('.learn-card-footer a');
44
+ expect(link.attributes('href')).toBe('https://www.glossarist.org/model/x');
45
+ expect(link.attributes('target')).toBe('_blank');
46
+ expect(link.attributes('rel')).toBe('noopener');
47
+ });
48
+
49
+ it('binds the id prop for deep-link anchors', () => {
50
+ const w = mount(LearnCard, {
51
+ props: { id: '__partitive__', title: 'x' },
52
+ });
53
+ expect(w.find('article').attributes('id')).toBe('__partitive__');
54
+ });
55
+ });
56
+
57
+ describe('DimensionCard', () => {
58
+ beforeEach(() => setActivePinia(createPinia()));
59
+
60
+ it('renders one row per value', () => {
61
+ const w = mount(DimensionCard, {
62
+ props: {
63
+ name: 'Presence',
64
+ summary: 'Compulsory or optional',
65
+ values: [
66
+ { value: 'required', label: 'required', meaning: 'must be present' },
67
+ { value: 'optional', label: 'optional', meaning: 'may be absent' },
68
+ ],
69
+ },
70
+ });
71
+ expect(w.findAll('.dimension-value-row').length).toBe(2);
72
+ });
73
+
74
+ it('renders value labels and meanings', () => {
75
+ const w = mount(DimensionCard, {
76
+ props: {
77
+ name: 'x',
78
+ summary: 's',
79
+ values: [{ value: 'required', label: 'required', meaning: 'must be present' }],
80
+ },
81
+ });
82
+ const row = w.find('.dimension-value-row');
83
+ expect(row.text()).toContain('required');
84
+ expect(row.text()).toContain('must be present');
85
+ });
86
+
87
+ it('exposes a visual scoped slot per value', () => {
88
+ const w = mount(DimensionCard, {
89
+ props: {
90
+ name: 'x',
91
+ summary: 's',
92
+ values: [{ value: 'required', label: 'required', meaning: 'm' }],
93
+ },
94
+ slots: {
95
+ visual: '<svg data-test="custom-visual" />',
96
+ },
97
+ });
98
+ expect(w.find('svg[data-test="custom-visual"]').exists()).toBe(true);
99
+ });
100
+
101
+ it('binds the id prop for deep-link anchors', () => {
102
+ const w = mount(DimensionCard, {
103
+ props: {
104
+ id: 'partitive-presence',
105
+ name: 'x',
106
+ summary: 's',
107
+ values: [{ value: 'required', label: 'required', meaning: 'm' }],
108
+ },
109
+ });
110
+ expect(w.find('article').attributes('id')).toBe('partitive-presence');
111
+ });
112
+ });
@@ -0,0 +1,318 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { drawRakeBundles } from '../../components/relation-sphere/rake-bundles';
3
+ import type { PartitiveRelationWire, PartitiveMemberWire } from '../../adapters/types';
4
+ import {
5
+ NORMAL_STROKE_WIDTH,
6
+ DELIMITING_STROKE_WIDTH,
7
+ } from '../../utils/partitive-multiplicity';
8
+
9
+ /**
10
+ * Renderer tests for the partitive-relation rake bundle.
11
+ *
12
+ * Mirrors `pipe-bundles.test.ts` — pins the visual contract so a
13
+ * regression in stem/spine/teeth geometry, multiplicity stroke style,
14
+ * or partial-completeness spine extension cannot ship silently.
15
+ *
16
+ * Layout reference (ISO 704 rake notation):
17
+ *
18
+ * comprehensive
19
+ * │ ← stem (FRAME_STROKE_WIDTH, single solid)
20
+ * ●───────── ← spine (FRAME_STROKE_WIDTH, single solid)
21
+ * │ │
22
+ * ▼ ▼ ← teeth (per-member multiplicity + delimiting style)
23
+ * p1 p2
24
+ */
25
+ function makeSvg(): SVGSVGElement {
26
+ return document.createElementNS('http://www.w3.org/2000/svg', 'svg') as unknown as SVGSVGElement;
27
+ }
28
+
29
+ function member(uri: string, fields: Partial<PartitiveMemberWire> = {}): PartitiveMemberWire {
30
+ return {
31
+ uri,
32
+ presence: fields.presence ?? 'required',
33
+ count: fields.count ?? 'exactly_one',
34
+ isDelimiting: fields.isDelimiting ?? false,
35
+ };
36
+ }
37
+
38
+ function makeRelation(overrides: Partial<PartitiveRelationWire> = {}): PartitiveRelationWire {
39
+ return {
40
+ source: 'https://example.org/vim-2012/concept/1.3',
41
+ comprehensive: 'https://example.org/vim-2012/concept/1.3',
42
+ completeness: 'complete',
43
+ register: 'vim-2012',
44
+ partitives: [
45
+ member('https://example.org/vim-2012/concept/1.4'),
46
+ member('https://example.org/vim-2012/concept/1.5'),
47
+ member('https://example.org/vim-2012/concept/1.22'),
48
+ ],
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ /* Fixed position map: comp at top-center, 3 members fanned across the bottom.
54
+ The rake renderer projects members onto a perpendicular spine; absolute
55
+ coordinates don't matter for the contract assertions, only that each
56
+ member has a distinct position so the spine has non-zero length. */
57
+ const pos = new Map<string, { x: number; y: number }>([
58
+ ['vim-2012/1.3', { x: 120, y: 20 }],
59
+ ['vim-2012/1.4', { x: 30, y: 140 }],
60
+ ['vim-2012/1.5', { x: 120, y: 140 }],
61
+ ['vim-2012/1.22', { x: 210, y: 140 }],
62
+ ]);
63
+
64
+ describe('drawRakeBundles — rake contract', () => {
65
+ it('renders nothing when muted', () => {
66
+ const svg = makeSvg();
67
+ drawRakeBundles(svg, pos, {
68
+ isDark: false,
69
+ color: '#0d9488',
70
+ isMuted: true,
71
+ relations: [makeRelation()],
72
+ });
73
+ expect(svg.querySelectorAll('line.rake-seg').length).toBe(0);
74
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
75
+ expect(svg.querySelectorAll('path').length).toBe(0);
76
+ });
77
+
78
+ it('renders a diamond at the comprehensive end (origin marker)', () => {
79
+ const svg = makeSvg();
80
+ drawRakeBundles(svg, pos, {
81
+ isDark: false,
82
+ color: '#0d9488',
83
+ isMuted: false,
84
+ relations: [makeRelation()],
85
+ });
86
+ /* Diamond is the only <path> element the rake emits. */
87
+ const paths = svg.querySelectorAll('path');
88
+ expect(paths.length).toBe(1);
89
+ /* Diamond path is centered on the comp position (120, 20).
90
+ The first coordinate of the diamond "M cx (cy-half)" pattern. */
91
+ const d = paths[0].getAttribute('d') ?? '';
92
+ expect(d.startsWith('M 120 ')).toBe(true);
93
+ });
94
+
95
+ it('renders exactly one circular junction at the spine center', () => {
96
+ const svg = makeSvg();
97
+ drawRakeBundles(svg, pos, {
98
+ isDark: false,
99
+ color: '#0d9488',
100
+ isMuted: false,
101
+ relations: [makeRelation()],
102
+ });
103
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(1);
104
+ });
105
+
106
+ it('does NOT emit pipe-and-thread artifacts (no shape leak)', () => {
107
+ const svg = makeSvg();
108
+ drawRakeBundles(svg, pos, {
109
+ isDark: false,
110
+ color: '#0d9488',
111
+ isMuted: false,
112
+ relations: [makeRelation()],
113
+ });
114
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
115
+ expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(0);
116
+ });
117
+
118
+ it('renders frame-width stem + spine at FRAME_STROKE_WIDTH (1.4)', () => {
119
+ const svg = makeSvg();
120
+ drawRakeBundles(svg, pos, {
121
+ isDark: false,
122
+ color: '#0d9488',
123
+ isMuted: false,
124
+ relations: [makeRelation()],
125
+ });
126
+ const segs = Array.from(svg.querySelectorAll('line.rake-seg'));
127
+ const frameSegs = segs.filter(s => Math.abs(parseFloat(s.getAttribute('stroke-width')!) - 1.4) < 0.01);
128
+ /* Stem + spine = 2 frame-width segments. */
129
+ expect(frameSegs.length).toBe(2);
130
+ });
131
+
132
+ it('tooth stroke width reflects isDelimiting (NORMAL vs 3× DELIMITING)', () => {
133
+ const svg = makeSvg();
134
+ /* All members required/exactly_one, but only the second is delimiting.
135
+ Expected tooth widths: NORMAL, DELIMITING (3×), NORMAL. */
136
+ const rel = makeRelation({
137
+ partitives: [
138
+ member('https://example.org/vim-2012/concept/1.4'),
139
+ member('https://example.org/vim-2012/concept/1.5', { isDelimiting: true }),
140
+ member('https://example.org/vim-2012/concept/1.22'),
141
+ ],
142
+ });
143
+ drawRakeBundles(svg, pos, {
144
+ isDark: false,
145
+ color: '#0d9488',
146
+ isMuted: false,
147
+ relations: [rel],
148
+ });
149
+ const segs = Array.from(svg.querySelectorAll('line.rake-seg'));
150
+ const widths = segs.map(s => parseFloat(s.getAttribute('stroke-width')!));
151
+ /* Stem + spine are 1.4; non-delimiting teeth are NORMAL (1.5);
152
+ delimiting teeth are DELIMITING (4.5). */
153
+ expect(widths.filter(w => Math.abs(w - NORMAL_STROKE_WIDTH) < 0.01).length).toBe(2);
154
+ expect(widths.filter(w => Math.abs(w - DELIMITING_STROKE_WIDTH) < 0.01).length).toBe(1);
155
+ });
156
+
157
+ it('count=multiple renders 2 parallel tooth lines (lineCount=2)', () => {
158
+ const svg = makeSvg();
159
+ /* One member with count=multiple. Counting segments:
160
+ - 1 stem (FRAME)
161
+ - 1 spine (FRAME) — only if spine extents > 1px; with a single
162
+ member the spine is degenerate (zero length). The renderer
163
+ guards with `if (Math.abs(maxAlong - minAlong) > 1)`, so spine
164
+ is skipped for single-member input.
165
+ - 2 tooth lines (lineCount=2 for count=multiple).
166
+ Total: 1 stem + 2 teeth = 3 segments. */
167
+ const rel = makeRelation({
168
+ partitives: [
169
+ member('https://example.org/vim-2012/concept/1.4', { count: 'multiple' }),
170
+ member('https://example.org/vim-2012/concept/1.5', { count: 'multiple' }),
171
+ ],
172
+ });
173
+ drawRakeBundles(svg, pos, {
174
+ isDark: false,
175
+ color: '#0d9488',
176
+ isMuted: false,
177
+ relations: [rel],
178
+ });
179
+ const segs = svg.querySelectorAll('line.rake-seg');
180
+ /* 2 members × 2 lines each = 4 teeth, + 1 stem + 1 spine = 6. */
181
+ expect(segs.length).toBe(6);
182
+ });
183
+
184
+ it('presence=optional makes the tooth dashed', () => {
185
+ const svg = makeSvg();
186
+ const rel = makeRelation({
187
+ partitives: [
188
+ member('https://example.org/vim-2012/concept/1.4', { presence: 'optional' }),
189
+ member('https://example.org/vim-2012/concept/1.5', { presence: 'required' }),
190
+ ],
191
+ });
192
+ drawRakeBundles(svg, pos, {
193
+ isDark: false,
194
+ color: '#0d9488',
195
+ isMuted: false,
196
+ relations: [rel],
197
+ });
198
+ const segs = Array.from(svg.querySelectorAll('line.rake-seg'));
199
+ /* Teeth are the segments at NORMAL_STROKE_WIDTH (1.5) — frame
200
+ segments are at 1.4. Filter to teeth, then check dash pattern. */
201
+ const teeth = segs.filter(s => Math.abs(parseFloat(s.getAttribute('stroke-width')!) - NORMAL_STROKE_WIDTH) < 0.01);
202
+ expect(teeth.length).toBeGreaterThanOrEqual(2);
203
+ /* presence=optional tooth is dashed; presence=required is solid.
204
+ At least one dashed, at least one solid. */
205
+ const dashed = teeth.filter(t => t.getAttribute('stroke-dasharray'));
206
+ const solid = teeth.filter(t => !t.getAttribute('stroke-dasharray'));
207
+ expect(dashed.length).toBeGreaterThanOrEqual(1);
208
+ expect(solid.length).toBeGreaterThanOrEqual(1);
209
+ });
210
+
211
+ it('partial completeness extends the spine beyond the member bounding box', () => {
212
+ /* Members at x = {30, 120, 210}; their bbox X is [30, 210].
213
+ With completeness='partial', PARTIAL_TAIL (28px) is appended to
214
+ the spine extent — so the spine endpoint must land OUTSIDE [30, 210].
215
+ With completeness='complete' the spine stays inside the bbox. */
216
+ const members = [
217
+ member('https://example.org/vim-2012/concept/1.4'),
218
+ member('https://example.org/vim-2012/concept/1.5'),
219
+ member('https://example.org/vim-2012/concept/1.22'),
220
+ ];
221
+ const completeSvg = makeSvg();
222
+ const partialSvg = makeSvg();
223
+ drawRakeBundles(completeSvg, pos, {
224
+ isDark: false, color: '#0d9488', isMuted: false,
225
+ relations: [makeRelation({ completeness: 'complete', partitives: members })],
226
+ });
227
+ drawRakeBundles(partialSvg, pos, {
228
+ isDark: false, color: '#0d9488', isMuted: false,
229
+ relations: [makeRelation({ completeness: 'partial', partitives: members })],
230
+ });
231
+ /* Frame-width (1.4) segments are stem + spine. For complete, all
232
+ spine frame-segment endpoints lie within the member X bbox [30,210].
233
+ For partial, at least one endpoint lies outside. */
234
+ const isFrame = (s: Element) => Math.abs(parseFloat(s.getAttribute('stroke-width')!) - 1.4) < 0.01;
235
+ const endpointsOutsideBbox = (svg: SVGSVGElement) => {
236
+ return Array.from(svg.querySelectorAll('line.rake-seg'))
237
+ .filter(isFrame)
238
+ .some(s => {
239
+ const x1 = parseFloat(s.getAttribute('x1')!);
240
+ const x2 = parseFloat(s.getAttribute('x2')!);
241
+ return x1 < 30 || x1 > 210 || x2 < 30 || x2 > 210;
242
+ });
243
+ };
244
+ expect(endpointsOutsideBbox(completeSvg)).toBe(false);
245
+ expect(endpointsOutsideBbox(partialSvg)).toBe(true);
246
+ });
247
+
248
+ it('renders a degenerate rake (stem + 1 tooth, no spine) when only 1 member has a position', () => {
249
+ /* The renderer does not enforce ≥2 members — the model layer does.
250
+ Given 1 member, the rake produces a stem and a single tooth, no
251
+ spine (maxAlong - minAlong = 0, so the spine is skipped). */
252
+ const svg = makeSvg();
253
+ const sparsePos = new Map<string, { x: number; y: number }>([
254
+ ['vim-2012/1.3', { x: 120, y: 20 }],
255
+ ['vim-2012/1.4', { x: 30, y: 140 }],
256
+ /* 1.5 and 1.22 missing — only 1 member with a position */
257
+ ]);
258
+ drawRakeBundles(svg, sparsePos, {
259
+ isDark: false, color: '#0d9488', isMuted: false,
260
+ relations: [makeRelation()],
261
+ });
262
+ /* 1 stem (frame) + 1 tooth (normal) = 2 segments. */
263
+ expect(svg.querySelectorAll('line.rake-seg').length).toBe(2);
264
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(1);
265
+ });
266
+
267
+ it('skips the relation entirely when the comprehensive is missing from pos', () => {
268
+ const svg = makeSvg();
269
+ const sparsePos = new Map<string, { x: number; y: number }>([
270
+ ['vim-2012/1.4', { x: 30, y: 140 }],
271
+ ['vim-2012/1.5', { x: 120, y: 140 }],
272
+ /* comprehensive 1.3 missing — compPos lookup fails, relation skipped */
273
+ ]);
274
+ drawRakeBundles(svg, sparsePos, {
275
+ isDark: false, color: '#0d9488', isMuted: false,
276
+ relations: [makeRelation()],
277
+ });
278
+ expect(svg.querySelectorAll('line.rake-seg').length).toBe(0);
279
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
280
+ expect(svg.querySelectorAll('path').length).toBe(0);
281
+ });
282
+
283
+ it('renders a separate junction + diamond per relation when multiple are present', () => {
284
+ const svg = makeSvg();
285
+ /* Two distinct comprehensives → two rake bundles. */
286
+ const multiPos = new Map<string, { x: number; y: number }>([
287
+ ['vim-2012/1.3', { x: 60, y: 20 }],
288
+ ['vim-2012/1.4', { x: 30, y: 140 }],
289
+ ['vim-2012/1.5', { x: 90, y: 140 }],
290
+ ['vim-2012/2.9', { x: 180, y: 20 }],
291
+ ['vim-2012/2.10', { x: 150, y: 140 }],
292
+ ['vim-2012/2.26', { x: 210, y: 140 }],
293
+ ]);
294
+ drawRakeBundles(svg, multiPos, {
295
+ isDark: false, color: '#0d9488', isMuted: false,
296
+ relations: [
297
+ makeRelation({
298
+ comprehensive: 'https://example.org/vim-2012/concept/1.3',
299
+ source: 'https://example.org/vim-2012/concept/1.3',
300
+ partitives: [
301
+ member('https://example.org/vim-2012/concept/1.4'),
302
+ member('https://example.org/vim-2012/concept/1.5'),
303
+ ],
304
+ }),
305
+ makeRelation({
306
+ comprehensive: 'https://example.org/vim-2012/concept/2.9',
307
+ source: 'https://example.org/vim-2012/concept/2.9',
308
+ partitives: [
309
+ member('https://example.org/vim-2012/concept/2.10'),
310
+ member('https://example.org/vim-2012/concept/2.26'),
311
+ ],
312
+ }),
313
+ ],
314
+ });
315
+ expect(svg.querySelectorAll('circle.rake-junction').length).toBe(2);
316
+ expect(svg.querySelectorAll('path').length).toBe(2);
317
+ });
318
+ });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import { createRouter, createMemoryHistory } from 'vue-router';
5
+ import LearnDesignationsView from '../../../views/learn/LearnDesignationsView.vue';
6
+
7
+ async function makeRouter() {
8
+ const router = createRouter({
9
+ history: createMemoryHistory(),
10
+ routes: [
11
+ { path: '/', name: 'home', component: { template: '<div/>' } },
12
+ { path: '/learn/designations', name: 'learn-designations', component: LearnDesignationsView },
13
+ ],
14
+ });
15
+ await router.push('/learn/designations');
16
+ await router.isReady();
17
+ return router;
18
+ }
19
+
20
+ describe('LearnDesignationsView', () => {
21
+ beforeEach(() => setActivePinia(createPinia()));
22
+
23
+ it('renders the page header', async () => {
24
+ const router = await makeRouter();
25
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
26
+ expect(w.find('h1').text()).toContain('Designations');
27
+ });
28
+
29
+ it('renders three top-level sections (normative, kinds, types)', async () => {
30
+ const router = await makeRouter();
31
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
32
+ const sections = w.findAll('section[aria-label]');
33
+ expect(sections.length).toBe(3);
34
+ });
35
+
36
+ it('normative section has all three ratings (preferred/admitted/deprecated)', async () => {
37
+ const router = await makeRouter();
38
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
39
+ const normative = w.find('section[aria-label="Normative status"]');
40
+ const cardTitles = normative.findAll('h3').map(h => h.text());
41
+ expect(cardTitles).toEqual(expect.arrayContaining([
42
+ 'preferred term', 'admitted term', 'deprecated term',
43
+ ]));
44
+ });
45
+
46
+ it('each normative card notes "there can be more than one"', async () => {
47
+ /* The user's specific concern: explain that there can be multiple. */
48
+ const router = await makeRouter();
49
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
50
+ const normative = w.find('section[aria-label="Normative status"]');
51
+ const notes = normative.findAll('.italic').map(i => i.text().toLowerCase());
52
+ expect(notes.filter(t => t.includes('more than one')).length).toBe(3);
53
+ });
54
+
55
+ it('kinds section renders all four kinds (full form, abbreviated, borrowed, variant)', async () => {
56
+ const router = await makeRouter();
57
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
58
+ const kinds = w.find('section[aria-label="Kinds and forms of term"]');
59
+ const titles = kinds.findAll('h3').map(h => h.text());
60
+ expect(titles).toEqual(expect.arrayContaining([
61
+ 'full form', 'abbreviated form', 'borrowed term', 'variant',
62
+ ]));
63
+ });
64
+
65
+ it('types section renders term, acronym, initialism, symbol', async () => {
66
+ const router = await makeRouter();
67
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
68
+ const types = w.find('section[aria-label="Designation types"]');
69
+ const titles = types.findAll('h3').map(h => h.text());
70
+ expect(titles).toEqual(expect.arrayContaining([
71
+ 'term (expression)', 'acronym', 'initialism', 'symbol',
72
+ ]));
73
+ });
74
+
75
+ it('every example slot mentions a concrete ISO example', async () => {
76
+ const router = await makeRouter();
77
+ const w = mount(LearnDesignationsView, { global: { plugins: [router] } });
78
+ const examples = w.findAll('.learn-card-example').map(e => e.text());
79
+ expect(examples.some(t => t.includes('compact disc'))).toBe(true);
80
+ expect(examples.some(t => t.includes('WWW'))).toBe(true);
81
+ expect(examples.some(t => t.includes('laser'))).toBe(true);
82
+ expect(examples.some(t => t.includes('OIML'))).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+ import { createRouter, createMemoryHistory } from 'vue-router';
5
+ import LearnIndexView from '../../../views/learn/LearnIndexView.vue';
6
+
7
+ async function makeRouter() {
8
+ const router = createRouter({
9
+ history: createMemoryHistory(),
10
+ routes: [
11
+ { path: '/', name: 'home', component: { template: '<div/>' } },
12
+ { path: '/learn', name: 'learn', component: LearnIndexView },
13
+ { path: '/learn/relationships', name: 'learn-relationships', component: { template: '<div/>' } },
14
+ { path: '/learn/designations', name: 'learn-designations', component: { template: '<div/>' } },
15
+ { path: '/learn/statuses', name: 'learn-statuses', component: { template: '<div/>' } },
16
+ ],
17
+ });
18
+ await router.push('/learn');
19
+ await router.isReady();
20
+ return router;
21
+ }
22
+
23
+ describe('LearnIndexView', () => {
24
+ beforeEach(() => setActivePinia(createPinia()));
25
+
26
+ it('renders the page header with translated title', async () => {
27
+ const router = await makeRouter();
28
+ const w = mount(LearnIndexView, { global: { plugins: [router] } });
29
+ expect(w.find('h1').text()).toContain('Learn');
30
+ });
31
+
32
+ it('renders exactly 3 topic cards for Phase 1', async () => {
33
+ const router = await makeRouter();
34
+ const w = mount(LearnIndexView, { global: { plugins: [router] } });
35
+ /* Each RouterLink wraps a LearnCard. Phase 1 = 3 topics. */
36
+ expect(w.findAllComponents({ name: 'LearnCard' }).length).toBe(3);
37
+ });
38
+
39
+ it('each topic card links to one of the Phase 1 routes', async () => {
40
+ const router = await makeRouter();
41
+ const w = mount(LearnIndexView, { global: { plugins: [router] } });
42
+ const links = w.findAll('a').map(a => a.attributes('href'));
43
+ expect(links).toEqual(expect.arrayContaining(['/learn/relationships', '/learn/designations', '/learn/statuses']));
44
+ });
45
+ });