@glossarist/concept-browser 0.7.77 → 0.7.80
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/astro.config.mjs +1 -0
- package/package.json +2 -2
- package/scripts/bridge-to-astro.mjs +6 -1
- package/scripts/build-edges.js +154 -1
- package/scripts/generate-data.mjs +138 -54
- package/src/__tests__/build-edges-redundancy.test.ts +101 -0
- package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
- package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
- package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
- package/src/__tests__/config/page-types.test.ts +82 -0
- package/src/__tests__/model-bridge-bridges.test.ts +3 -2
- package/src/__tests__/model-bridge-citation.test.ts +1 -1
- package/src/__tests__/ontology-registry.test.ts +2 -2
- package/src/__tests__/partitive-relation-styling.test.ts +82 -0
- package/src/__tests__/partitive-relation.test.ts +285 -0
- package/src/__tests__/sphere-math.test.ts +56 -0
- package/src/adapters/GraphDataSource.ts +22 -5
- package/src/adapters/UriRouter.ts +5 -0
- package/src/adapters/dataset-stats.ts +13 -0
- package/src/adapters/model-bridge.ts +1 -1
- package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
- package/src/adapters/types.ts +36 -0
- package/src/components/AppSidebar.vue +14 -57
- package/src/components/ConceptDetail.vue +29 -0
- package/src/components/ConceptDiffModal.vue +120 -0
- package/src/components/ConceptDiffView.vue +192 -0
- package/src/components/ConceptEditionRail.vue +61 -36
- package/src/components/PartitiveRelationList.vue +139 -0
- package/src/components/RelationSphere.vue +2 -36
- package/src/components/groups/DatasetGroupRenderer.vue +14 -4
- package/src/components/groups/DefaultGroupSidebar.vue +28 -11
- package/src/components/groups/LineageGroupSidebar.vue +17 -29
- package/src/composables/use-concept-diff.ts +231 -0
- package/src/composables/use-concept-edges.ts +98 -5
- package/src/composables/use-dataset-stats.ts +52 -0
- package/src/config/page-types.ts +67 -0
- package/src/config/types.ts +6 -1
- package/src/config/use-site-config.ts +13 -29
- package/src/data/taxonomies.json +24 -1
- package/src/i18n/locales/eng.yml +6 -0
- package/src/utils/partitive-relation-styling.ts +62 -0
- package/src/utils/sphere-math.ts +54 -0
- package/src/views/DatasetView.vue +5 -3
- package/src/views/StatsView.vue +9 -22
- package/src/islands/BibliographyIsland.vue +0 -26
- package/src/islands/ConceptDetailIsland.vue +0 -27
- package/src/islands/ConceptDiffIsland.vue +0 -214
- package/src/islands/ConceptIsland.vue +0 -102
- package/src/islands/GraphIsland.vue +0 -15
- package/src/islands/GroupRendererIsland.vue +0 -15
- package/src/islands/HeaderIsland.vue +0 -10
- package/src/islands/NonVerbalIsland.vue +0 -75
- package/src/islands/RelationSphereIsland.vue +0 -20
- package/src/islands/SearchIsland.vue +0 -23
- package/src/islands/SeriesCardIsland.vue +0 -12
- package/src/islands/ShortcutsIsland.vue +0 -13
- package/src/islands/SidebarIsland.vue +0 -15
- package/src/islands/SupersessionDiffIsland.vue +0 -267
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for warnBinaryPartitiveRedundancy.
|
|
5
|
+
*
|
|
6
|
+
* The function lives inside scripts/build-edges.js (not exported). To
|
|
7
|
+
* test it in isolation we re-implement the same logic here and verify
|
|
8
|
+
* the behavior. The production function uses console.warn — we check
|
|
9
|
+
* the contract by inspecting what gets warned.
|
|
10
|
+
*/
|
|
11
|
+
const BINARY_PARTITIVE_TYPES = new Set(['has_part', 'is_part_of', 'broader_partitive', 'narrower_partitive']);
|
|
12
|
+
|
|
13
|
+
interface TestEdge {
|
|
14
|
+
source: string;
|
|
15
|
+
target: string;
|
|
16
|
+
type: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TestRelation {
|
|
20
|
+
comprehensive: string;
|
|
21
|
+
partitives?: Array<{ uri?: string } | string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findRedundantBinaries(edges: TestEdge[], relations: TestRelation[]): TestEdge[] {
|
|
25
|
+
if (!relations?.length) return [];
|
|
26
|
+
const partitivePairs = new Set<string>();
|
|
27
|
+
for (const rel of relations) {
|
|
28
|
+
const members = rel.partitives ?? [];
|
|
29
|
+
for (const member of members) {
|
|
30
|
+
const memberUri = typeof member === 'string' ? member : (member.uri ?? '');
|
|
31
|
+
partitivePairs.add(`${rel.comprehensive}|${memberUri}`);
|
|
32
|
+
partitivePairs.add(`${memberUri}|${rel.comprehensive}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return edges.filter((e: TestEdge) =>
|
|
36
|
+
BINARY_PARTITIVE_TYPES.has(e.type)
|
|
37
|
+
&& partitivePairs.has(`${e.source}|${e.target}`),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('binary has_part vs PartitiveRelation redundancy (TODO item 14)', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns no redundancies when PartitiveRelation has no members', () => {
|
|
51
|
+
const edges = [{ source: 'A', target: 'B', type: 'has_part' }];
|
|
52
|
+
const relations = [{ comprehensive: 'A', partitives: [] }];
|
|
53
|
+
expect(findRedundantBinaries(edges, relations)).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns no redundancies when there are no binary has_part edges', () => {
|
|
57
|
+
const edges = [{ source: 'A', target: 'B', type: 'related_concept' }];
|
|
58
|
+
const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }, { uri: 'C' }] }];
|
|
59
|
+
expect(findRedundantBinaries(edges, relations)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('flags binary has_part that duplicates a PartitiveRelation member', () => {
|
|
63
|
+
const edges = [
|
|
64
|
+
{ source: 'A', target: 'B', type: 'has_part' },
|
|
65
|
+
{ source: 'A', target: 'C', type: 'has_part' },
|
|
66
|
+
];
|
|
67
|
+
const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }, { uri: 'C' }, { uri: 'D' }] }];
|
|
68
|
+
const flagged = findRedundantBinaries(edges, relations);
|
|
69
|
+
expect(flagged).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('flags inverse direction (is_part_of)', () => {
|
|
73
|
+
const edges = [{ source: 'B', target: 'A', type: 'is_part_of' }];
|
|
74
|
+
const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }] }];
|
|
75
|
+
expect(findRedundantBinaries(edges, relations)).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not flag non-partitive binary types', () => {
|
|
79
|
+
const edges = [
|
|
80
|
+
{ source: 'A', target: 'B', type: 'broader_generic' },
|
|
81
|
+
{ source: 'A', target: 'B', type: 'related_concept' },
|
|
82
|
+
];
|
|
83
|
+
const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }] }];
|
|
84
|
+
expect(findRedundantBinaries(edges, relations)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('handles broader_partitive / narrower_partitive', () => {
|
|
88
|
+
const edges = [
|
|
89
|
+
{ source: 'A', target: 'B', type: 'broader_partitive' },
|
|
90
|
+
{ source: 'B', target: 'A', type: 'narrower_partitive' },
|
|
91
|
+
];
|
|
92
|
+
const relations = [{ comprehensive: 'A', partitives: [{ uri: 'B' }, { uri: 'C' }] }];
|
|
93
|
+
expect(findRedundantBinaries(edges, relations)).toHaveLength(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('ignores legacy v1 parts field (no partitives key present)', () => {
|
|
97
|
+
const edges: TestEdge[] = [{ source: 'A', target: 'B', type: 'has_part' }];
|
|
98
|
+
const relations = [{ comprehensive: 'A' }] as TestRelation[];
|
|
99
|
+
expect(findRedundantBinaries(edges, relations)).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
toDiffableConcept,
|
|
4
|
+
deriveDiffSections,
|
|
5
|
+
computeSimilarity,
|
|
6
|
+
useConceptDiff,
|
|
7
|
+
} from '../../composables/use-concept-diff';
|
|
8
|
+
import type { ConceptDiff } from 'glossarist/diff';
|
|
9
|
+
|
|
10
|
+
describe('useConceptDiff', () => {
|
|
11
|
+
describe('toDiffableConcept', () => {
|
|
12
|
+
it('reads id from conceptId', () => {
|
|
13
|
+
const c = toDiffableConcept({ conceptId: '1.2.3' });
|
|
14
|
+
expect(c.id).toBe('1.2.3');
|
|
15
|
+
expect(c.termid).toBe('1.2.3');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('falls back to id', () => {
|
|
19
|
+
const c = toDiffableConcept({ id: 'abc' });
|
|
20
|
+
expect(c.id).toBe('abc');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('collects languages from localizations', () => {
|
|
24
|
+
const c = toDiffableConcept({
|
|
25
|
+
id: 'x',
|
|
26
|
+
localizations: { eng: {}, fra: {} },
|
|
27
|
+
});
|
|
28
|
+
expect(c.languages).toEqual(['eng', 'fra']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns null from localization() for unknown lang', () => {
|
|
32
|
+
const c = toDiffableConcept({ id: 'x', localizations: { eng: {} } });
|
|
33
|
+
expect(c.localization('deu')).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('localization() returns terms/definitions/notes/examples with defaults', () => {
|
|
37
|
+
const c = toDiffableConcept({
|
|
38
|
+
id: 'x',
|
|
39
|
+
localizations: { eng: { terms: ['t'] } },
|
|
40
|
+
});
|
|
41
|
+
const lc = c.localization('eng');
|
|
42
|
+
expect(lc).not.toBeNull();
|
|
43
|
+
expect(lc?.terms).toEqual(['t']);
|
|
44
|
+
expect(lc?.definitions).toEqual([]);
|
|
45
|
+
expect(lc?.languageCode).toBe('eng');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('deriveDiffSections', () => {
|
|
50
|
+
it('returns empty for null', () => {
|
|
51
|
+
expect(deriveDiffSections(null, 'eng')).toEqual({});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns empty when no localization for lang', () => {
|
|
55
|
+
const diff = {
|
|
56
|
+
localization: () => null,
|
|
57
|
+
localizations: {},
|
|
58
|
+
} as unknown as ConceptDiff;
|
|
59
|
+
expect(deriveDiffSections(diff, 'eng')).toEqual({});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('extracts added definition', () => {
|
|
63
|
+
const diff = {
|
|
64
|
+
localization: () => ({
|
|
65
|
+
definitions: { added: [{ value: { content: 'new def' } }] },
|
|
66
|
+
}),
|
|
67
|
+
} as unknown as ConceptDiff;
|
|
68
|
+
const s = deriveDiffSections(diff, 'eng');
|
|
69
|
+
expect(s.definition?.type).toBe('added');
|
|
70
|
+
expect(s.definition?.value).toBe('new def');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('extracts removed definition', () => {
|
|
74
|
+
const diff = {
|
|
75
|
+
localization: () => ({
|
|
76
|
+
definitions: { removed: [{ value: { content: 'old def' } }] },
|
|
77
|
+
}),
|
|
78
|
+
} as unknown as ConceptDiff;
|
|
79
|
+
const s = deriveDiffSections(diff, 'eng');
|
|
80
|
+
expect(s.definition?.type).toBe('removed');
|
|
81
|
+
expect(s.definition?.value).toBe('old def');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('extracts changed definition hunks', () => {
|
|
85
|
+
const hunks = [
|
|
86
|
+
{ type: 'equal', text: 'a' },
|
|
87
|
+
{ type: 'added', text: 'b' },
|
|
88
|
+
];
|
|
89
|
+
const diff = {
|
|
90
|
+
localization: () => ({
|
|
91
|
+
definitions: { changed: [{ textDiff: { hunks } }] },
|
|
92
|
+
}),
|
|
93
|
+
} as unknown as ConceptDiff;
|
|
94
|
+
const s = deriveDiffSections(diff, 'eng');
|
|
95
|
+
expect(s.definition?.hunks).toEqual(hunks);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('extracts designations (added + removed + changed)', () => {
|
|
99
|
+
const diff = {
|
|
100
|
+
localization: () => ({
|
|
101
|
+
designations: {
|
|
102
|
+
added: [{ value: { designation: 'new' } }],
|
|
103
|
+
removed: [{ value: { designation: 'old' } }],
|
|
104
|
+
changed: [{ oldValue: { designation: 'a' }, newValue: { designation: 'b' } }],
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
} as unknown as ConceptDiff;
|
|
108
|
+
const s = deriveDiffSections(diff, 'eng');
|
|
109
|
+
expect(s.designations?.items).toEqual([
|
|
110
|
+
{ type: 'added', text: 'new' },
|
|
111
|
+
{ type: 'removed', text: 'old' },
|
|
112
|
+
{ type: 'removed', text: 'a' },
|
|
113
|
+
{ type: 'added', text: 'b' },
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('extracts notes and examples', () => {
|
|
118
|
+
const diff = {
|
|
119
|
+
localization: () => ({
|
|
120
|
+
notes: { added: [{ value: { content: 'n1' } }] },
|
|
121
|
+
examples: { removed: [{ value: { content: 'e1' } }] },
|
|
122
|
+
}),
|
|
123
|
+
} as unknown as ConceptDiff;
|
|
124
|
+
const s = deriveDiffSections(diff, 'eng');
|
|
125
|
+
expect(s.notes?.items).toEqual([{ type: 'added', text: 'n1' }]);
|
|
126
|
+
expect(s.examples?.items).toEqual([{ type: 'removed', text: 'e1' }]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('prefers changed hunks over added/removed when both present', () => {
|
|
130
|
+
const diff = {
|
|
131
|
+
localization: () => ({
|
|
132
|
+
definitions: {
|
|
133
|
+
changed: [{ textDiff: { hunks: [{ type: 'equal', text: 'x' }] } }],
|
|
134
|
+
added: [{ value: 'should-be-ignored' }],
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
} as unknown as ConceptDiff;
|
|
138
|
+
const s = deriveDiffSections(diff, 'eng');
|
|
139
|
+
expect(s.definition?.hunks).toHaveLength(1);
|
|
140
|
+
expect(s.definition?.type).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('computeSimilarity', () => {
|
|
145
|
+
it('returns null for null diff', () => {
|
|
146
|
+
expect(computeSimilarity(null)).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns null when no stats', () => {
|
|
150
|
+
const diff = {} as ConceptDiff;
|
|
151
|
+
expect(computeSimilarity(diff)).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns null when total is 0', () => {
|
|
155
|
+
const diff = { stats: { added: 0, removed: 0, changed: 0, total: 0 } } as unknown as ConceptDiff;
|
|
156
|
+
expect(computeSimilarity(diff)).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns 1.0 when nothing changed', () => {
|
|
160
|
+
const diff = { stats: { added: 0, removed: 0, changed: 0, total: 10 } } as unknown as ConceptDiff;
|
|
161
|
+
expect(computeSimilarity(diff)).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns 0 when everything changed', () => {
|
|
165
|
+
const diff = { stats: { added: 5, removed: 5, changed: 0, total: 10 } } as unknown as ConceptDiff;
|
|
166
|
+
expect(computeSimilarity(diff)).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('computes partial similarity', () => {
|
|
170
|
+
const diff = { stats: { added: 1, removed: 1, changed: 0, total: 10 } } as unknown as ConceptDiff;
|
|
171
|
+
expect(computeSimilarity(diff)).toBe(0.8);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('clamps to [0, 1]', () => {
|
|
175
|
+
const over = { stats: { added: 100, removed: 100, changed: 100, total: 10 } } as unknown as ConceptDiff;
|
|
176
|
+
expect(computeSimilarity(over)).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('useConceptDiff composable', () => {
|
|
181
|
+
it('starts empty', () => {
|
|
182
|
+
const { diffResult, loading, error } = useConceptDiff();
|
|
183
|
+
expect(diffResult.value).toBeNull();
|
|
184
|
+
expect(loading.value).toBe(false);
|
|
185
|
+
expect(error.value).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('clear() resets state', () => {
|
|
189
|
+
const { diffResult, error, clear } = useConceptDiff();
|
|
190
|
+
diffResult.value = {} as ConceptDiff;
|
|
191
|
+
error.value = 'oops';
|
|
192
|
+
clear();
|
|
193
|
+
expect(diffResult.value).toBeNull();
|
|
194
|
+
expect(error.value).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('diff() sets a ConceptDiff for two real concepts', () => {
|
|
198
|
+
const { diffResult, diff } = useConceptDiff();
|
|
199
|
+
const oldData = {
|
|
200
|
+
id: '1',
|
|
201
|
+
localizations: {
|
|
202
|
+
eng: { terms: [{ designation: 'old term', type: 'expression', normative_status: 'preferred' }] },
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const newData = {
|
|
206
|
+
id: '1',
|
|
207
|
+
localizations: {
|
|
208
|
+
eng: { terms: [{ designation: 'new term', type: 'expression', normative_status: 'preferred' }] },
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
diff(oldData, newData);
|
|
212
|
+
expect(diffResult.value).not.toBeNull();
|
|
213
|
+
expect(diffResult.value?.oldId).toBe('1');
|
|
214
|
+
expect(diffResult.value?.newId).toBe('1');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('diff() against identical concepts reports hasChanges=false', () => {
|
|
218
|
+
const { diffResult, diff } = useConceptDiff();
|
|
219
|
+
const data = {
|
|
220
|
+
id: '1',
|
|
221
|
+
localizations: {
|
|
222
|
+
eng: { terms: [{ designation: 'same', type: 'expression', normative_status: 'preferred' }] },
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
diff(data, data);
|
|
226
|
+
expect(diffResult.value?.hasChanges).toBe(false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('diff() catches errors and exposes them', () => {
|
|
230
|
+
const { error, diffResult, diff } = useConceptDiff();
|
|
231
|
+
// A concept whose `localization` throws — simulates malformed input
|
|
232
|
+
const bad = {
|
|
233
|
+
get localizations() { throw new Error('boom'); },
|
|
234
|
+
} as any;
|
|
235
|
+
diff(bad, bad);
|
|
236
|
+
expect(error.value).toBeTruthy();
|
|
237
|
+
expect(diffResult.value).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PartitiveRelation, PartitiveMember, TypeSharedPlurality, ConceptRef } from 'glossarist';
|
|
3
|
+
import {
|
|
4
|
+
PartitiveRelation as PartitiveRelationModel,
|
|
5
|
+
PartitiveMember as PartitiveMemberModel,
|
|
6
|
+
TypeSharedPlurality as TypeSharedPluralityModel,
|
|
7
|
+
ConceptRef as ConceptRefModel,
|
|
8
|
+
} from 'glossarist/models';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Verifies glossarist-js's native v2 PartitiveRelation model.
|
|
12
|
+
*
|
|
13
|
+
* The concept-browser now consumes this directly (no `as any`
|
|
14
|
+
* bridge). These specs lock in the contract so an upstream
|
|
15
|
+
* regression is caught here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function makeRef(source: string, id: string): ConceptRef {
|
|
19
|
+
return ConceptRefModel.fromJSON({ source, id }) as ConceptRef;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makePartitive(source: string, id: string, certainty?: 'confirmed' | 'possible'): PartitiveMember {
|
|
23
|
+
const data: { ref: ConceptRef; certainty?: 'confirmed' | 'possible' } = { ref: makeRef(source, id) };
|
|
24
|
+
if (certainty) data.certainty = certainty;
|
|
25
|
+
return PartitiveMemberModel.fromJSON(data as unknown as Record<string, unknown>) as PartitiveMember;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makePlurality(isShared: boolean, isUncertain = false): TypeSharedPlurality {
|
|
29
|
+
return TypeSharedPluralityModel.fromJSON({ isShared, isUncertain }) as TypeSharedPlurality;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeRelation(opts: {
|
|
33
|
+
comprehensive: ConceptRef;
|
|
34
|
+
partitives: PartitiveMember[];
|
|
35
|
+
completeness?: 'complete' | 'partial';
|
|
36
|
+
plurality?: TypeSharedPlurality | null;
|
|
37
|
+
criterion?: Record<string, string>;
|
|
38
|
+
}): PartitiveRelation {
|
|
39
|
+
return PartitiveRelationModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveRelation;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('native v2 PartitiveRelation model (glossarist 0.4.20)', () => {
|
|
43
|
+
it('PartitiveMember.certainty defaults to confirmed when omitted', () => {
|
|
44
|
+
const m = makePartitive('test', '1.4');
|
|
45
|
+
expect(m.certainty).toBe('confirmed');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('PartitiveMember.certainty is preserved when set to possible', () => {
|
|
49
|
+
const m = makePartitive('test', '1.4', 'possible');
|
|
50
|
+
expect(m.certainty).toBe('possible');
|
|
51
|
+
expect(m.isPossible).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('PartitiveMember.ref exposes the typed ConceptRef', () => {
|
|
55
|
+
const m = makePartitive('test', '1.4');
|
|
56
|
+
expect(m.ref.source).toBe('test');
|
|
57
|
+
expect(m.ref.id).toBe('1.4');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('PartitiveRelation has completeness default of complete', () => {
|
|
61
|
+
const r = makeRelation({
|
|
62
|
+
comprehensive: makeRef('test', '1.3'),
|
|
63
|
+
partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
|
|
64
|
+
});
|
|
65
|
+
expect(r.completeness).toBe('complete');
|
|
66
|
+
expect(r.isComplete).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('PartitiveRelation preserves explicit partial completeness', () => {
|
|
70
|
+
const r = makeRelation({
|
|
71
|
+
comprehensive: makeRef('test', '1.3'),
|
|
72
|
+
partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
|
|
73
|
+
completeness: 'partial',
|
|
74
|
+
});
|
|
75
|
+
expect(r.completeness).toBe('partial');
|
|
76
|
+
expect(r.isPartial).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('PartitiveRelation accepts plurality', () => {
|
|
80
|
+
const r = makeRelation({
|
|
81
|
+
comprehensive: makeRef('test', '1.3'),
|
|
82
|
+
partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
|
|
83
|
+
plurality: makePlurality(true, true),
|
|
84
|
+
});
|
|
85
|
+
expect(r.hasPlurality()).toBe(true);
|
|
86
|
+
expect(r.plurality?.isShared).toBe(true);
|
|
87
|
+
expect(r.plurality?.isUncertain).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('PartitiveRelation accepts criterion as localized hash', () => {
|
|
91
|
+
const r = makeRelation({
|
|
92
|
+
comprehensive: makeRef('test', '1.3'),
|
|
93
|
+
partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
|
|
94
|
+
criterion: { eng: 'physical structure', fra: 'structure physique' },
|
|
95
|
+
});
|
|
96
|
+
expect(r.hasCriterion()).toBe(true);
|
|
97
|
+
expect(r.criterion?.eng).toBe('physical structure');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects < 2 partitives (ISO 704 requirement)', () => {
|
|
101
|
+
expect(() => makeRelation({
|
|
102
|
+
comprehensive: makeRef('test', '1.3'),
|
|
103
|
+
partitives: [makePartitive('test', '1.4')],
|
|
104
|
+
})).toThrow(/≥2 partitives/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects self-loops', () => {
|
|
108
|
+
expect(() => makeRelation({
|
|
109
|
+
comprehensive: makeRef('test', '1.3'),
|
|
110
|
+
partitives: [
|
|
111
|
+
makePartitive('test', '1.3'),
|
|
112
|
+
makePartitive('test', '1.4'),
|
|
113
|
+
],
|
|
114
|
+
})).toThrow(/self-loop/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('round-trips through toJSON with v2 shape', () => {
|
|
118
|
+
const r = makeRelation({
|
|
119
|
+
comprehensive: makeRef('test', '1.3'),
|
|
120
|
+
partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5', 'possible')],
|
|
121
|
+
completeness: 'partial',
|
|
122
|
+
plurality: makePlurality(true),
|
|
123
|
+
criterion: { eng: 'criterion text' },
|
|
124
|
+
});
|
|
125
|
+
const json = r.toJSON();
|
|
126
|
+
expect(json.completeness).toBe('partial');
|
|
127
|
+
expect(json.partitives).toHaveLength(2);
|
|
128
|
+
expect(json.partitives[1].certainty).toBe('possible');
|
|
129
|
+
expect(json.plurality?.is_shared).toBe(true);
|
|
130
|
+
expect(json.criterion?.eng).toBe('criterion text');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ref, nextTick } from 'vue';
|
|
3
|
+
import { useDatasetStats } from '../../composables/use-dataset-stats';
|
|
4
|
+
|
|
5
|
+
const okResponse = (body: unknown) => ({
|
|
6
|
+
ok: true,
|
|
7
|
+
json: async () => body,
|
|
8
|
+
});
|
|
9
|
+
const notFoundResponse = () => ({ ok: false, json: async () => null });
|
|
10
|
+
|
|
11
|
+
describe('useDatasetStats', () => {
|
|
12
|
+
let originalFetch: typeof globalThis.fetch;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
originalFetch = globalThis.fetch;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
globalThis.fetch = originalFetch;
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('starts with null stats', async () => {
|
|
24
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(null) as any);
|
|
25
|
+
const { stats, loading } = useDatasetStats(() => 'reg-1', '/');
|
|
26
|
+
expect(stats.value).toBeNull();
|
|
27
|
+
await nextTick();
|
|
28
|
+
await nextTick();
|
|
29
|
+
await nextTick();
|
|
30
|
+
expect(loading.value).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('loads stats.json for the resolved registerId', async () => {
|
|
34
|
+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
35
|
+
okResponse({
|
|
36
|
+
sourceCount: 5,
|
|
37
|
+
sources: [],
|
|
38
|
+
relationshipCount: 12,
|
|
39
|
+
relationshipTypes: { supersedes: 3 },
|
|
40
|
+
}) as any,
|
|
41
|
+
);
|
|
42
|
+
const { stats } = useDatasetStats(() => 'reg-1', '/');
|
|
43
|
+
await nextTick();
|
|
44
|
+
await nextTick();
|
|
45
|
+
expect(fetchMock).toHaveBeenCalledWith('/data/reg-1/stats.json');
|
|
46
|
+
expect(stats.value?.sourceCount).toBe(5);
|
|
47
|
+
expect(stats.value?.relationshipCount).toBe(12);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles 404 gracefully (stats stays null)', async () => {
|
|
51
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(notFoundResponse() as any);
|
|
52
|
+
const { stats, error } = useDatasetStats(() => 'reg-1', '/');
|
|
53
|
+
await nextTick();
|
|
54
|
+
await nextTick();
|
|
55
|
+
expect(stats.value).toBeNull();
|
|
56
|
+
expect(error.value).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles network errors and exposes error message', async () => {
|
|
60
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'));
|
|
61
|
+
const { stats, error } = useDatasetStats(() => 'reg-1', '/');
|
|
62
|
+
await nextTick();
|
|
63
|
+
await nextTick();
|
|
64
|
+
await nextTick();
|
|
65
|
+
expect(stats.value).toBeNull();
|
|
66
|
+
expect(error.value).toBe('network down');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reacts to registerId change (Ref form)', async () => {
|
|
70
|
+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
71
|
+
okResponse({ sourceCount: 1, sources: [], relationshipCount: 0, relationshipTypes: {} }) as any,
|
|
72
|
+
);
|
|
73
|
+
const registerId = ref('reg-a');
|
|
74
|
+
useDatasetStats(registerId, '/');
|
|
75
|
+
await nextTick();
|
|
76
|
+
expect(fetchMock).toHaveBeenLastCalledWith('/data/reg-a/stats.json');
|
|
77
|
+
|
|
78
|
+
registerId.value = 'reg-b';
|
|
79
|
+
await nextTick();
|
|
80
|
+
await nextTick();
|
|
81
|
+
expect(fetchMock).toHaveBeenLastCalledWith('/data/reg-b/stats.json');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reload() re-fetches the same id', async () => {
|
|
85
|
+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
86
|
+
okResponse({ sourceCount: 7, sources: [], relationshipCount: 0, relationshipTypes: {} }) as any,
|
|
87
|
+
);
|
|
88
|
+
const { reload } = useDatasetStats(() => 'reg-1', '/');
|
|
89
|
+
await nextTick();
|
|
90
|
+
await nextTick();
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
92
|
+
reload();
|
|
93
|
+
await nextTick();
|
|
94
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PAGE_TYPES,
|
|
4
|
+
pageTypeOf,
|
|
5
|
+
synthesizePages,
|
|
6
|
+
} from '../../config/page-types';
|
|
7
|
+
|
|
8
|
+
describe('page-types', () => {
|
|
9
|
+
describe('PAGE_TYPES registry', () => {
|
|
10
|
+
it('includes home (global index)', () => {
|
|
11
|
+
expect(pageTypeOf('home')?.scope).toBe('global');
|
|
12
|
+
expect(pageTypeOf('home')?.route).toBe('');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('includes concepts (dataset index)', () => {
|
|
16
|
+
expect(pageTypeOf('concepts')?.scope).toBe('dataset');
|
|
17
|
+
expect(pageTypeOf('concepts')?.route).toBe('');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('includes sources (always synthesized for datasets)', () => {
|
|
21
|
+
const sources = pageTypeOf('sources');
|
|
22
|
+
expect(sources?.scope).toBe('dataset');
|
|
23
|
+
expect(sources?.featureFlag).toBeUndefined();
|
|
24
|
+
expect(sources?.autoSynthesize).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('each entry has unique type', () => {
|
|
28
|
+
const types = PAGE_TYPES.map(p => p.type);
|
|
29
|
+
expect(new Set(types).size).toBe(types.length);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('each entry has unique (scope, route) for autoSynthesized', () => {
|
|
33
|
+
const auto = PAGE_TYPES.filter(p => p.autoSynthesize);
|
|
34
|
+
const keys = auto.map(p => `${p.scope}:${p.route}`);
|
|
35
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('synthesizePages', () => {
|
|
40
|
+
it('synthesizes global pages respecting default-on features', () => {
|
|
41
|
+
const pages = synthesizePages('global', {}, new Set());
|
|
42
|
+
const routes = pages.map(p => p.route);
|
|
43
|
+
expect(routes).toContain('search');
|
|
44
|
+
expect(routes).toContain('graph');
|
|
45
|
+
expect(routes).toContain('ontology');
|
|
46
|
+
expect(routes).not.toContain('news');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('synthesizes news only when feature flag is truthy', () => {
|
|
50
|
+
const without = synthesizePages('global', {}, new Set());
|
|
51
|
+
const withFlag = synthesizePages('global', { news: true }, new Set());
|
|
52
|
+
expect(without.find(p => p.type === 'news')).toBeUndefined();
|
|
53
|
+
expect(withFlag.find(p => p.type === 'news')).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('respects feature off (search=false)', () => {
|
|
57
|
+
const pages = synthesizePages('global', { search: false }, new Set());
|
|
58
|
+
expect(pages.find(p => p.type === 'search')).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('synthesizes dataset pages', () => {
|
|
62
|
+
const pages = synthesizePages('dataset', {}, new Set());
|
|
63
|
+
const types = pages.map(p => p.type);
|
|
64
|
+
expect(types).toContain('concepts');
|
|
65
|
+
expect(types).toContain('stats');
|
|
66
|
+
expect(types).toContain('sources');
|
|
67
|
+
expect(types).toContain('about');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('skips routes already declared', () => {
|
|
71
|
+
const pages = synthesizePages('dataset', {}, new Set(['stats']));
|
|
72
|
+
expect(pages.find(p => p.type === 'stats')).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not cross-contaminate scopes', () => {
|
|
76
|
+
const global = synthesizePages('global', {}, new Set());
|
|
77
|
+
const dataset = synthesizePages('dataset', {}, new Set());
|
|
78
|
+
expect(global.find(p => p.scope === 'dataset')).toBeUndefined();
|
|
79
|
+
expect(dataset.find(p => p.scope === 'global')).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -135,8 +135,9 @@ describe('model-bridge — citation bridge round-trip', () => {
|
|
|
135
135
|
const lc = concept.localization('eng')!;
|
|
136
136
|
const rc = lc.related[0];
|
|
137
137
|
|
|
138
|
-
// All fields preserved through the bridge
|
|
139
|
-
|
|
138
|
+
// All fields preserved through the bridge (glossarist 0.4.20+
|
|
139
|
+
// normalizes content to a localized hash)
|
|
140
|
+
expect(rc.content).toEqual({ default: 'entity' });
|
|
140
141
|
expect(getRelatedSourceId(rc)).toBe('vim-2.2');
|
|
141
142
|
|
|
142
143
|
const citation = getRelatedCitation(rc)!;
|
|
@@ -60,7 +60,7 @@ describe('model-bridge — reference citation mapping via WeakMap bridges', () =
|
|
|
60
60
|
const lc = concept.localization('eng')!;
|
|
61
61
|
const related = lc.related;
|
|
62
62
|
expect(related).toHaveLength(1);
|
|
63
|
-
expect(related[0].content).
|
|
63
|
+
expect(related[0].content).toEqual({ default: 'entity data type' });
|
|
64
64
|
expect(getRelatedSourceId(related[0])).toBe('iso-10303-2-def');
|
|
65
65
|
});
|
|
66
66
|
|