@glossarist/concept-browser 0.7.43 → 0.7.45
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/cli/index.mjs +12 -13
- package/package.json +3 -2
- package/scripts/__tests__/fetch-datasets.test.mjs +105 -0
- package/scripts/fetch-datasets.mjs +53 -51
- package/scripts/generate-data.mjs +41 -19
- package/scripts/lib/build/image-assets.mjs +190 -0
- package/scripts/lib/build/non-verbal-consumer.mjs +221 -0
- package/scripts/lib/local-path-safety.mjs +68 -0
- package/src/__tests__/bibliography-adapter.test.ts +79 -0
- package/src/__tests__/content-renderer-nvr-mentions.test.ts +57 -0
- package/src/__tests__/locale.test.ts +46 -0
- package/src/__tests__/model-bridge-entity-refs.test.ts +114 -0
- package/src/__tests__/non-verbal-anchor.test.ts +33 -0
- package/src/__tests__/non-verbal-cross-ref.test.ts +146 -0
- package/src/__tests__/non-verbal-highlight.test.ts +56 -0
- package/src/__tests__/non-verbal-kind.test.ts +77 -0
- package/src/__tests__/non-verbal-list.test.ts +67 -0
- package/src/__tests__/non-verbal-rep-display.test.ts +85 -0
- package/src/__tests__/non-verbal-scroll-guard.test.ts +116 -0
- package/src/__tests__/use-concept-entities.test.ts +76 -0
- package/src/adapters/bibliography-adapter.ts +49 -0
- package/src/adapters/factory.ts +14 -0
- package/src/adapters/model-bridge.ts +51 -0
- package/src/adapters/non-verbal/figure-bridge.ts +101 -0
- package/src/adapters/non-verbal/formula-bridge.ts +48 -0
- package/src/adapters/non-verbal/index.ts +55 -0
- package/src/adapters/non-verbal/kind.ts +46 -0
- package/src/adapters/non-verbal/prefix.ts +67 -0
- package/src/adapters/non-verbal/source-bridge.ts +81 -0
- package/src/adapters/non-verbal/table-bridge.ts +98 -0
- package/src/adapters/non-verbal/types.ts +133 -0
- package/src/adapters/non-verbal-resolver.ts +101 -0
- package/src/components/ConceptDetail.vue +17 -4
- package/src/components/LanguageDetail.vue +0 -3
- package/src/components/NonVerbalRepDisplay.vue +82 -24
- package/src/components/figure/FigureDisplay.vue +132 -0
- package/src/components/figure/FigureImages.vue +111 -0
- package/src/components/figure/figure-image-pick.ts +56 -0
- package/src/components/figure/figure-layout.ts +26 -0
- package/src/components/formula/FormulaDisplay.vue +90 -0
- package/src/components/formula/FormulaExpression.vue +70 -0
- package/src/components/non-verbal/NonVerbalCaption.vue +104 -0
- package/src/components/non-verbal/NonVerbalFallback.vue +69 -0
- package/src/components/non-verbal/NonVerbalList.vue +118 -0
- package/src/components/non-verbal/NonVerbalSources.vue +61 -0
- package/src/components/table/TableDisplay.vue +99 -0
- package/src/components/table/TableMarkup.vue +63 -0
- package/src/components/table/TableStructured.vue +66 -0
- package/src/composables/use-concept-entities.ts +70 -0
- package/src/composables/use-non-verbal-cross-ref.ts +79 -0
- package/src/composables/use-non-verbal-entity.ts +58 -0
- package/src/composables/use-reduced-motion.ts +26 -0
- package/src/composables/use-render-options.ts +30 -33
- package/src/router/index.ts +3 -0
- package/src/router/non-verbal-scroll-guard.ts +56 -0
- package/src/style.css +17 -0
- package/src/utils/content-renderer.ts +76 -64
- package/src/utils/locale.ts +92 -0
- package/src/utils/non-verbal-anchor.ts +51 -0
- package/src/utils/non-verbal-highlight.ts +27 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { defineComponent, h } from 'vue';
|
|
3
|
+
import { mount } from '@vue/test-utils';
|
|
4
|
+
import { useNonVerbalCrossRef } from '../composables/use-non-verbal-cross-ref';
|
|
5
|
+
|
|
6
|
+
const Host = defineComponent({
|
|
7
|
+
setup() {
|
|
8
|
+
const { navigateToEntity } = useNonVerbalCrossRef();
|
|
9
|
+
return { navigateToEntity };
|
|
10
|
+
},
|
|
11
|
+
render() {
|
|
12
|
+
return h('div');
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function setHash(href: string) {
|
|
17
|
+
// jsdom-compatible history stub
|
|
18
|
+
Object.defineProperty(window, 'location', {
|
|
19
|
+
configurable: true,
|
|
20
|
+
value: { ...window.location, hash: '' },
|
|
21
|
+
});
|
|
22
|
+
// Some jsdom builds make location readonly; fall back to defining on the prototype chain.
|
|
23
|
+
try {
|
|
24
|
+
window.location.hash = href;
|
|
25
|
+
} catch {
|
|
26
|
+
Object.defineProperty(window.location, 'hash', { configurable: true, value: href });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('useNonVerbalCrossRef', () => {
|
|
31
|
+
let pushSpy: ReturnType<typeof vi.spyOn>;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
pushSpy = vi.spyOn(window.history, 'pushState').mockImplementation(() => undefined);
|
|
36
|
+
setHash('');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.useRealTimers();
|
|
41
|
+
vi.restoreAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function click(target: HTMLElement): void {
|
|
45
|
+
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
46
|
+
target.dispatchEvent(ev);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('activates the target when a figure anchor link is clicked', async () => {
|
|
50
|
+
const wrapper = mount(Host, { attachTo: document.body });
|
|
51
|
+
const figure = document.createElement('figure');
|
|
52
|
+
figure.id = 'figure-ds-mixed';
|
|
53
|
+
const scrollSpy = vi.spyOn(figure, 'scrollIntoView').mockImplementation(() => undefined);
|
|
54
|
+
const focusSpy = vi.spyOn(figure, 'focus').mockImplementation(() => undefined);
|
|
55
|
+
document.body.appendChild(figure);
|
|
56
|
+
|
|
57
|
+
const link = document.createElement('a');
|
|
58
|
+
link.href = '#figure-ds-mixed';
|
|
59
|
+
link.textContent = 'Fig 1';
|
|
60
|
+
document.body.appendChild(link);
|
|
61
|
+
|
|
62
|
+
click(link);
|
|
63
|
+
|
|
64
|
+
expect(scrollSpy).toHaveBeenCalled();
|
|
65
|
+
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
|
|
66
|
+
expect(figure.classList.contains('nv-entity--highlighted')).toBe(true);
|
|
67
|
+
expect(pushSpy).toHaveBeenCalledWith(null, '', '#figure-ds-mixed');
|
|
68
|
+
|
|
69
|
+
document.body.removeChild(figure);
|
|
70
|
+
document.body.removeChild(link);
|
|
71
|
+
wrapper.unmount();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('responds to table and formula anchor prefixes', async () => {
|
|
75
|
+
const wrapper = mount(Host, { attachTo: document.body });
|
|
76
|
+
|
|
77
|
+
for (const kind of ['table', 'formula'] as const) {
|
|
78
|
+
const target = document.createElement('div');
|
|
79
|
+
target.id = `${kind}-ds-foo`;
|
|
80
|
+
const scrollSpy = vi.spyOn(target, 'scrollIntoView').mockImplementation(() => undefined);
|
|
81
|
+
document.body.appendChild(target);
|
|
82
|
+
|
|
83
|
+
const link = document.createElement('a');
|
|
84
|
+
link.href = `#${kind}-ds-foo`;
|
|
85
|
+
document.body.appendChild(link);
|
|
86
|
+
|
|
87
|
+
click(link);
|
|
88
|
+
expect(scrollSpy).toHaveBeenCalled();
|
|
89
|
+
vi.mocked(scrollSpy).mockRestore();
|
|
90
|
+
|
|
91
|
+
document.body.removeChild(target);
|
|
92
|
+
document.body.removeChild(link);
|
|
93
|
+
}
|
|
94
|
+
wrapper.unmount();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('ignores clicks on links that are not entity anchors', async () => {
|
|
98
|
+
const wrapper = mount(Host, { attachTo: document.body });
|
|
99
|
+
const otherEl = document.createElement('div');
|
|
100
|
+
otherEl.id = 'something-else';
|
|
101
|
+
const scrollSpy = vi.spyOn(otherEl, 'scrollIntoView').mockImplementation(() => undefined);
|
|
102
|
+
document.body.appendChild(otherEl);
|
|
103
|
+
|
|
104
|
+
const link = document.createElement('a');
|
|
105
|
+
link.href = '#something-else';
|
|
106
|
+
document.body.appendChild(link);
|
|
107
|
+
|
|
108
|
+
click(link);
|
|
109
|
+
expect(scrollSpy).not.toHaveBeenCalled();
|
|
110
|
+
expect(pushSpy).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
document.body.removeChild(otherEl);
|
|
113
|
+
document.body.removeChild(link);
|
|
114
|
+
wrapper.unmount();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not activate when the target element is missing', async () => {
|
|
118
|
+
const wrapper = mount(Host, { attachTo: document.body });
|
|
119
|
+
const link = document.createElement('a');
|
|
120
|
+
link.href = '#figure-ds-missing';
|
|
121
|
+
document.body.appendChild(link);
|
|
122
|
+
|
|
123
|
+
expect(() => click(link)).not.toThrow();
|
|
124
|
+
expect(pushSpy).not.toHaveBeenCalled();
|
|
125
|
+
|
|
126
|
+
document.body.removeChild(link);
|
|
127
|
+
wrapper.unmount();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('navigateToEntity activates a known target programmatically', async () => {
|
|
131
|
+
const wrapper = mount(Host, { attachTo: document.body });
|
|
132
|
+
const target = document.createElement('figure');
|
|
133
|
+
target.id = 'figure-ds-prog';
|
|
134
|
+
const scrollSpy = vi.spyOn(target, 'scrollIntoView').mockImplementation(() => undefined);
|
|
135
|
+
document.body.appendChild(target);
|
|
136
|
+
|
|
137
|
+
(wrapper.vm as unknown as { navigateToEntity: (id: string) => void })
|
|
138
|
+
.navigateToEntity('figure-ds-prog');
|
|
139
|
+
|
|
140
|
+
expect(scrollSpy).toHaveBeenCalled();
|
|
141
|
+
expect(target.classList.contains('nv-entity--highlighted')).toBe(true);
|
|
142
|
+
|
|
143
|
+
document.body.removeChild(target);
|
|
144
|
+
wrapper.unmount();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { highlightEntity, scrollToEntity } from '../utils/non-verbal-highlight';
|
|
3
|
+
|
|
4
|
+
describe('non-verbal-highlight', () => {
|
|
5
|
+
let el: HTMLElement;
|
|
6
|
+
let focusSpy: ReturnType<typeof vi.spyOn>;
|
|
7
|
+
let scrollIntoViewSpy: ReturnType<typeof vi.spyOn>;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
el = document.createElement('div');
|
|
11
|
+
focusSpy = vi.spyOn(el, 'focus').mockImplementation(() => undefined);
|
|
12
|
+
scrollIntoViewSpy = vi.spyOn(el, 'scrollIntoView').mockImplementation(() => undefined);
|
|
13
|
+
vi.useFakeTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('adds the highlight class and tabindex', () => {
|
|
22
|
+
highlightEntity(el);
|
|
23
|
+
expect(el.classList.contains('nv-entity--highlighted')).toBe(true);
|
|
24
|
+
expect(el.getAttribute('tabindex')).toBe('-1');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('focuses the element without scrolling', () => {
|
|
28
|
+
highlightEntity(el);
|
|
29
|
+
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('removes the highlight class after the duration', () => {
|
|
33
|
+
highlightEntity(el);
|
|
34
|
+
expect(el.classList.contains('nv-entity--highlighted')).toBe(true);
|
|
35
|
+
vi.advanceTimersByTime(2000);
|
|
36
|
+
expect(el.classList.contains('nv-entity--highlighted')).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is a no-op for null target', () => {
|
|
40
|
+
expect(() => highlightEntity(null)).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('scrollToEntity uses smooth behavior when smooth=true', () => {
|
|
44
|
+
scrollToEntity(el, true);
|
|
45
|
+
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('scrollToEntity uses auto behavior when smooth=false', () => {
|
|
49
|
+
scrollToEntity(el, false);
|
|
50
|
+
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('scrollToEntity is a no-op for null target', () => {
|
|
54
|
+
expect(() => scrollToEntity(null, true)).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
KIND_TO_DIR,
|
|
4
|
+
KIND_TO_BRIDGE,
|
|
5
|
+
ALL_KINDS,
|
|
6
|
+
MENTION_KIND_TO_ENTITY_KIND,
|
|
7
|
+
entityKindFromMentionKind,
|
|
8
|
+
kindFromType,
|
|
9
|
+
} from '../adapters/non-verbal/kind';
|
|
10
|
+
|
|
11
|
+
describe('non-verbal kind dispatch', () => {
|
|
12
|
+
describe('KIND_TO_DIR', () => {
|
|
13
|
+
it('maps each kind to its plural directory name', () => {
|
|
14
|
+
expect(KIND_TO_DIR.figure).toBe('figures');
|
|
15
|
+
expect(KIND_TO_DIR.table).toBe('tables');
|
|
16
|
+
expect(KIND_TO_DIR.formula).toBe('formulas');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('is frozen (immutable at runtime)', () => {
|
|
20
|
+
expect(Object.isFrozen(KIND_TO_DIR)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('ALL_KINDS', () => {
|
|
25
|
+
it('includes figure, table, formula', () => {
|
|
26
|
+
expect(ALL_KINDS).toContain('figure');
|
|
27
|
+
expect(ALL_KINDS).toContain('table');
|
|
28
|
+
expect(ALL_KINDS).toContain('formula');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('KIND_TO_BRIDGE', () => {
|
|
33
|
+
it('maps every kind to a function', () => {
|
|
34
|
+
for (const kind of ALL_KINDS) {
|
|
35
|
+
expect(typeof KIND_TO_BRIDGE[kind]).toBe('function');
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('MENTION_KIND_TO_ENTITY_KIND', () => {
|
|
41
|
+
it('maps parseMention kinds to entity kinds', () => {
|
|
42
|
+
expect(MENTION_KIND_TO_ENTITY_KIND['fig-ref']).toBe('figure');
|
|
43
|
+
expect(MENTION_KIND_TO_ENTITY_KIND['table-ref']).toBe('table');
|
|
44
|
+
expect(MENTION_KIND_TO_ENTITY_KIND['formula-ref']).toBe('formula');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('entityKindFromMentionKind', () => {
|
|
49
|
+
it('returns the entity kind for known mention kinds', () => {
|
|
50
|
+
expect(entityKindFromMentionKind('fig-ref')).toBe('figure');
|
|
51
|
+
expect(entityKindFromMentionKind('table-ref')).toBe('table');
|
|
52
|
+
expect(entityKindFromMentionKind('formula-ref')).toBe('formula');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null for unknown mention kinds', () => {
|
|
56
|
+
expect(entityKindFromMentionKind('cite-ref')).toBeNull();
|
|
57
|
+
expect(entityKindFromMentionKind('numeric')).toBeNull();
|
|
58
|
+
expect(entityKindFromMentionKind('unknown')).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('kindFromType', () => {
|
|
63
|
+
it('accepts gl: prefix', () => {
|
|
64
|
+
expect(kindFromType('gl:Figure')).toBe('figure');
|
|
65
|
+
expect(kindFromType('gl:Table')).toBe('table');
|
|
66
|
+
expect(kindFromType('gl:Formula')).toBe('formula');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('accepts gloss: prefix', () => {
|
|
70
|
+
expect(kindFromType('gloss:Figure')).toBe('figure');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('returns null for unknown type', () => {
|
|
74
|
+
expect(kindFromType('gl:Unknown')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import NonVerbalList from '../components/non-verbal/NonVerbalList.vue';
|
|
4
|
+
import type { StructuralEntityRef } from '../composables/use-concept-entities';
|
|
5
|
+
|
|
6
|
+
function makeRef(overrides: Partial<StructuralEntityRef> = {}): StructuralEntityRef {
|
|
7
|
+
return {
|
|
8
|
+
kind: 'figure',
|
|
9
|
+
entityId: 'mixed-reflection',
|
|
10
|
+
display: null,
|
|
11
|
+
anchor: 'figure-ds-mixed-reflection',
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('NonVerbalList.vue', () => {
|
|
17
|
+
it('renders nothing when refs is empty', () => {
|
|
18
|
+
const wrapper = mount(NonVerbalList, { props: { refs: [] } });
|
|
19
|
+
expect(wrapper.find('.section-label').exists()).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders one group per kind', () => {
|
|
23
|
+
const wrapper = mount(NonVerbalList, {
|
|
24
|
+
props: {
|
|
25
|
+
refs: [
|
|
26
|
+
makeRef({ kind: 'figure', entityId: 'foo', anchor: 'figure-ds-foo' }),
|
|
27
|
+
makeRef({ kind: 'table', entityId: 'bar', anchor: 'table-ds-bar' }),
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
expect(wrapper.text()).toContain('Figures');
|
|
32
|
+
expect(wrapper.text()).toContain('Tables');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('links to the anchor href', () => {
|
|
36
|
+
const wrapper = mount(NonVerbalList, {
|
|
37
|
+
props: {
|
|
38
|
+
refs: [makeRef({ kind: 'figure', entityId: 'mixed', anchor: 'figure-ds-mixed' })],
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const a = wrapper.find('a[href="#figure-ds-mixed"]');
|
|
42
|
+
expect(a.exists()).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('uses display when present, falls back to entityId', () => {
|
|
46
|
+
const wrapper = mount(NonVerbalList, {
|
|
47
|
+
props: {
|
|
48
|
+
refs: [
|
|
49
|
+
makeRef({ entityId: 'mixed', display: 'Figure 3', anchor: 'figure-ds-mixed' }),
|
|
50
|
+
makeRef({ entityId: 'plain', display: null, anchor: 'figure-ds-plain' }),
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(wrapper.text()).toContain('Figure 3');
|
|
55
|
+
expect(wrapper.text()).toContain('plain');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shows the raw ID as a secondary label when display is present', () => {
|
|
59
|
+
const wrapper = mount(NonVerbalList, {
|
|
60
|
+
props: {
|
|
61
|
+
refs: [makeRef({ entityId: 'dispersion-prism', display: 'Figure 3', anchor: 'figure-ds-x' })],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const item = wrapper.find('.nv-list__id');
|
|
65
|
+
expect(item.text()).toBe('dispersion-prism');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
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 type { NonVerbRep } from 'glossarist';
|
|
6
|
+
import NonVerbalRepDisplay from '../components/NonVerbalRepDisplay.vue';
|
|
7
|
+
import { resetFactory } from '../adapters/factory';
|
|
8
|
+
|
|
9
|
+
// glossarist-js's published `.d.ts` declares NonVerbRep's constructor with 0
|
|
10
|
+
// args, but the V3 runtime accepts a plain-data initializer. Cast through
|
|
11
|
+
// `unknown` at this boundary. See TODO.figures/19.
|
|
12
|
+
function makeRep(data: Record<string, unknown>): NonVerbRep {
|
|
13
|
+
return data as unknown as NonVerbRep;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeRouter() {
|
|
17
|
+
return createRouter({
|
|
18
|
+
history: createMemoryHistory(),
|
|
19
|
+
routes: [
|
|
20
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
21
|
+
{ path: '/dataset/:registerId/concept/:conceptId', name: 'concept', component: { template: '<div/>' } },
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('NonVerbalRepDisplay.vue — V3 shape', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
resetFactory();
|
|
29
|
+
setActivePinia(createPinia());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders nothing when reps is empty', () => {
|
|
33
|
+
const wrapper = mount(NonVerbalRepDisplay, {
|
|
34
|
+
props: { reps: [], locale: 'eng', registerId: 'ds' },
|
|
35
|
+
});
|
|
36
|
+
expect(wrapper.find('.section-label').exists()).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders a figure per rep', () => {
|
|
40
|
+
const rep = makeRep({
|
|
41
|
+
type: 'photograph',
|
|
42
|
+
images: [{ src: 'fig.png', format: 'png' }],
|
|
43
|
+
alt: { eng: 'A diagram' },
|
|
44
|
+
});
|
|
45
|
+
const wrapper = mount(NonVerbalRepDisplay, {
|
|
46
|
+
props: { reps: [rep], locale: 'eng', registerId: 'ds' },
|
|
47
|
+
});
|
|
48
|
+
expect(wrapper.findAll('figure').length).toBe(1);
|
|
49
|
+
expect(wrapper.text()).toContain('photograph');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders the localized caption when present', () => {
|
|
53
|
+
const rep = makeRep({
|
|
54
|
+
type: 'photo',
|
|
55
|
+
caption: { eng: 'Figure 1' },
|
|
56
|
+
});
|
|
57
|
+
const wrapper = mount(NonVerbalRepDisplay, {
|
|
58
|
+
props: { reps: [rep], locale: 'eng', registerId: 'ds' },
|
|
59
|
+
});
|
|
60
|
+
expect(wrapper.text()).toContain('Figure 1');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders sources when present', async () => {
|
|
64
|
+
const rep = makeRep({
|
|
65
|
+
type: 'photo',
|
|
66
|
+
sources: [
|
|
67
|
+
{
|
|
68
|
+
type: 'authoritative',
|
|
69
|
+
origin: {
|
|
70
|
+
ref: { source: 'ISO 123' },
|
|
71
|
+
locality: { type: 'clause', referenceFrom: '1.2' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
const router = makeRouter();
|
|
77
|
+
await router.push('/');
|
|
78
|
+
const wrapper = mount(NonVerbalRepDisplay, {
|
|
79
|
+
props: { reps: [rep], locale: 'eng', registerId: 'ds' },
|
|
80
|
+
global: { plugins: [router] },
|
|
81
|
+
});
|
|
82
|
+
expect(wrapper.text()).toContain('Sources');
|
|
83
|
+
expect(wrapper.text()).toContain('ISO 123');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
|
|
3
|
+
import { defineComponent, h } from 'vue';
|
|
4
|
+
|
|
5
|
+
vi.mock('../utils/non-verbal-highlight', () => ({
|
|
6
|
+
scrollToEntity: vi.fn(),
|
|
7
|
+
highlightEntity: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { scrollToEntity, highlightEntity } from '../utils/non-verbal-highlight';
|
|
11
|
+
import { installNonVerbalScroll } from '../router/non-verbal-scroll-guard';
|
|
12
|
+
|
|
13
|
+
const Stub = defineComponent({ render: () => h('div') });
|
|
14
|
+
|
|
15
|
+
function makeRouter(): Router {
|
|
16
|
+
return createRouter({
|
|
17
|
+
history: createMemoryHistory(),
|
|
18
|
+
routes: [
|
|
19
|
+
{ path: '/', name: 'home', component: Stub },
|
|
20
|
+
{ path: '/concept/:id', name: 'concept', component: Stub },
|
|
21
|
+
],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('installNonVerbalScroll', () => {
|
|
26
|
+
let matchMediaSpy: ReturnType<typeof vi.spyOn>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
vi.mocked(scrollToEntity).mockClear();
|
|
31
|
+
vi.mocked(highlightEntity).mockClear();
|
|
32
|
+
vi.mocked(scrollToEntity).mockImplementation(() => undefined);
|
|
33
|
+
vi.mocked(highlightEntity).mockImplementation(() => undefined);
|
|
34
|
+
matchMediaSpy = vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
35
|
+
matches: false,
|
|
36
|
+
addEventListener: () => undefined,
|
|
37
|
+
removeEventListener: () => undefined,
|
|
38
|
+
media: '',
|
|
39
|
+
addListener: () => undefined,
|
|
40
|
+
removeListener: () => undefined,
|
|
41
|
+
dispatchEvent: () => false,
|
|
42
|
+
onchange: null,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.useRealTimers();
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('scrolls and highlights when navigating to an entity hash', async () => {
|
|
52
|
+
const router = makeRouter();
|
|
53
|
+
installNonVerbalScroll(router);
|
|
54
|
+
|
|
55
|
+
const target = document.createElement('figure');
|
|
56
|
+
target.id = 'figure-ds-foo';
|
|
57
|
+
document.body.appendChild(target);
|
|
58
|
+
|
|
59
|
+
await router.push('/concept/1');
|
|
60
|
+
await router.push({ path: '/concept/1', hash: '#figure-ds-foo' });
|
|
61
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
62
|
+
|
|
63
|
+
expect(scrollToEntity).toHaveBeenCalledWith(target, true);
|
|
64
|
+
expect(highlightEntity).toHaveBeenCalledWith(target);
|
|
65
|
+
|
|
66
|
+
document.body.removeChild(target);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('passes smooth=false when the user prefers reduced motion', async () => {
|
|
70
|
+
matchMediaSpy.mockReturnValue({
|
|
71
|
+
matches: true,
|
|
72
|
+
addEventListener: () => undefined,
|
|
73
|
+
removeEventListener: () => undefined,
|
|
74
|
+
media: '',
|
|
75
|
+
addListener: () => undefined,
|
|
76
|
+
removeListener: () => undefined,
|
|
77
|
+
dispatchEvent: () => false,
|
|
78
|
+
onchange: null,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const router = makeRouter();
|
|
82
|
+
installNonVerbalScroll(router);
|
|
83
|
+
|
|
84
|
+
const target = document.createElement('figure');
|
|
85
|
+
target.id = 'figure-ds-reduced';
|
|
86
|
+
document.body.appendChild(target);
|
|
87
|
+
|
|
88
|
+
await router.push({ path: '/concept/2', hash: '#figure-ds-reduced' });
|
|
89
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
90
|
+
|
|
91
|
+
expect(scrollToEntity).toHaveBeenCalledWith(target, false);
|
|
92
|
+
|
|
93
|
+
document.body.removeChild(target);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('ignores hashes that are not entity anchors', async () => {
|
|
97
|
+
const router = makeRouter();
|
|
98
|
+
installNonVerbalScroll(router);
|
|
99
|
+
|
|
100
|
+
await router.push({ path: '/concept/3', hash: '#some-other-hash' });
|
|
101
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
102
|
+
|
|
103
|
+
expect(scrollToEntity).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('gives up after the polling timeout if the target never appears', async () => {
|
|
107
|
+
const router = makeRouter();
|
|
108
|
+
installNonVerbalScroll(router, { timeoutMs: 60 });
|
|
109
|
+
|
|
110
|
+
await router.push({ path: '/concept/4', hash: '#figure-ds-never' });
|
|
111
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
112
|
+
|
|
113
|
+
expect(scrollToEntity).not.toHaveBeenCalled();
|
|
114
|
+
expect(highlightEntity).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Concept } from 'glossarist';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { useConceptEntities } from '../composables/use-concept-entities';
|
|
5
|
+
|
|
6
|
+
function makeConcept(args: {
|
|
7
|
+
figures?: unknown[];
|
|
8
|
+
tables?: unknown[];
|
|
9
|
+
formulas?: unknown[];
|
|
10
|
+
}): Concept {
|
|
11
|
+
return Concept.fromJSON({
|
|
12
|
+
id: '2-1-145',
|
|
13
|
+
localizations: { eng: { language_code: 'eng' } },
|
|
14
|
+
figures: args.figures ?? [],
|
|
15
|
+
tables: args.tables ?? [],
|
|
16
|
+
formulas: args.formulas ?? [],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('useConceptEntities', () => {
|
|
21
|
+
it('returns an empty list when the concept has no structural refs', () => {
|
|
22
|
+
const concept = computed(() => makeConcept({}));
|
|
23
|
+
const datasetId = computed(() => 'iala-2023');
|
|
24
|
+
const refs = useConceptEntities(concept, datasetId);
|
|
25
|
+
expect(refs.value).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('pulls figure refs from concept.figures', () => {
|
|
29
|
+
const concept = computed(() => makeConcept({
|
|
30
|
+
figures: [{ ref: 'mixed-reflection' }],
|
|
31
|
+
}));
|
|
32
|
+
const refs = useConceptEntities(concept, computed(() => 'iala-2023'));
|
|
33
|
+
expect(refs.value).toHaveLength(1);
|
|
34
|
+
expect(refs.value[0]).toMatchObject({
|
|
35
|
+
kind: 'figure',
|
|
36
|
+
entityId: 'mixed-reflection',
|
|
37
|
+
anchor: 'figure-iala-2023-mixed-reflection',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('pulls table and formula refs alongside figures', () => {
|
|
42
|
+
const concept = computed(() => makeConcept({
|
|
43
|
+
figures: [{ ref: 'fig-1' }],
|
|
44
|
+
tables: [{ ref: 'tbl-1' }],
|
|
45
|
+
formulas: [{ ref: 'fml-1' }],
|
|
46
|
+
}));
|
|
47
|
+
const refs = useConceptEntities(concept, computed(() => 'ds'));
|
|
48
|
+
const kinds = refs.value.map(r => r.kind);
|
|
49
|
+
expect(kinds).toEqual(expect.arrayContaining(['figure', 'table', 'formula']));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('preserves display override', () => {
|
|
53
|
+
const concept = computed(() => makeConcept({
|
|
54
|
+
figures: [{ ref: 'dispersion-prism', display: 'Figure 3' }],
|
|
55
|
+
}));
|
|
56
|
+
const refs = useConceptEntities(concept, computed(() => 'ds'));
|
|
57
|
+
expect(refs.value[0].display).toBe('Figure 3');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sets display to null when no override is provided', () => {
|
|
61
|
+
const concept = computed(() => makeConcept({
|
|
62
|
+
figures: [{ ref: 'foo' }],
|
|
63
|
+
}));
|
|
64
|
+
const refs = useConceptEntities(concept, computed(() => 'ds'));
|
|
65
|
+
expect(refs.value[0].display).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('recomputes when datasetId changes', () => {
|
|
69
|
+
const concept = computed(() => makeConcept({
|
|
70
|
+
figures: [{ ref: 'foo' }],
|
|
71
|
+
}));
|
|
72
|
+
const datasetId = computed(() => 'first');
|
|
73
|
+
const refs = useConceptEntities(concept, datasetId);
|
|
74
|
+
expect(refs.value[0].anchor).toBe('figure-first-foo');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { BibliographyData, type BibliographyEntry } from 'glossarist';
|
|
2
|
+
|
|
3
|
+
export interface BibliographyAdapterOptions {
|
|
4
|
+
basePath?: string;
|
|
5
|
+
fetcher?: (url: string) => Promise<Response>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class BibliographyAdapter {
|
|
9
|
+
private data: BibliographyData | null = null;
|
|
10
|
+
private loaded = false;
|
|
11
|
+
private readonly basePath: string;
|
|
12
|
+
private readonly fetcher: (url: string) => Promise<Response>;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly datasetId: string,
|
|
16
|
+
opts: BibliographyAdapterOptions = {},
|
|
17
|
+
) {
|
|
18
|
+
this.basePath = opts.basePath ?? import.meta.env.BASE_URL ?? '/';
|
|
19
|
+
this.fetcher = opts.fetcher ?? ((url: string) => fetch(url));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async load(): Promise<void> {
|
|
23
|
+
if (this.loaded) return;
|
|
24
|
+
try {
|
|
25
|
+
const resp = await this.fetcher(`${this.basePath}data/${this.datasetId}/bibliography.json`);
|
|
26
|
+
if (resp.ok) {
|
|
27
|
+
const json = await resp.json();
|
|
28
|
+
this.data = BibliographyData.fromJSON(json);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Honest failure: loaded=true prevents retry storms; findById returns null.
|
|
32
|
+
} finally {
|
|
33
|
+
this.loaded = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
findById(id: string): BibliographyEntry | null {
|
|
38
|
+
return this.data?.find(id) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
all(): BibliographyEntry[] {
|
|
42
|
+
return this.data?.entries ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clear(): void {
|
|
46
|
+
this.data = null;
|
|
47
|
+
this.loaded = false;
|
|
48
|
+
}
|
|
49
|
+
}
|