@glossarist/concept-browser 0.7.85 → 0.7.86

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.
@@ -1,62 +1,30 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  partitiveRelationStyle,
4
- pluralityColor,
5
4
  completenessLabel,
6
- certaintyLabel,
7
5
  } from '../utils/partitive-relation-styling';
8
- import type { TypeSharedPluralityWire } from '../adapters/types';
9
6
 
10
7
  describe('partitive-relation-styling', () => {
11
- describe('partitiveRelationStyle', () => {
12
- it('returns full opacity for complete', () => {
13
- expect(partitiveRelationStyle('complete', null, false).opacity).toBe(1.0);
14
- });
15
-
16
- it('returns reduced opacity for partial', () => {
17
- expect(partitiveRelationStyle('partial', null, false).opacity).toBe(0.6);
18
- });
19
-
20
- it('uses teal badge class for complete with no plurality', () => {
21
- expect(partitiveRelationStyle('complete', null, false).badgeClass).toBe('badge-teal');
22
- });
23
-
24
- it('uses blue badge class for isShared plurality', () => {
25
- const p: TypeSharedPluralityWire = { isShared: true, isUncertain: false };
26
- expect(partitiveRelationStyle('complete', p, false).badgeClass).toBe('badge-blue');
27
- });
28
-
29
- it('uses yellow badge class when isShared + isUncertain', () => {
30
- const p: TypeSharedPluralityWire = { isShared: true, isUncertain: true };
31
- expect(partitiveRelationStyle('complete', p, false).badgeClass).toBe('badge-yellow');
8
+ describe('partitiveRelationStyle (relation-level frame)', () => {
9
+ it('returns teal color in light mode', () => {
10
+ expect(partitiveRelationStyle('complete', false).color).toBe('#0d9488');
32
11
  });
33
12
 
34
- it('returns different colors in dark mode', () => {
35
- const light = partitiveRelationStyle('complete', null, false);
36
- const dark = partitiveRelationStyle('complete', null, true);
37
- expect(light.color).not.toBe(dark.color);
13
+ it('returns teal color in dark mode', () => {
14
+ expect(partitiveRelationStyle('complete', true).color).toBe('#2dd4bf');
38
15
  });
39
16
 
40
- it('ignores plurality when isShared is false', () => {
41
- const p: TypeSharedPluralityWire = { isShared: false, isUncertain: true };
42
- expect(partitiveRelationStyle('complete', p, false).badgeClass).toBe('badge-teal');
43
- });
44
- });
45
-
46
- describe('pluralityColor', () => {
47
- it('returns blue for isShared', () => {
48
- const p: TypeSharedPluralityWire = { isShared: true, isUncertain: false };
49
- expect(pluralityColor(p, false)).toBe('#3b82f6');
17
+ it('returns full opacity for complete', () => {
18
+ expect(partitiveRelationStyle('complete', false).opacity).toBe(1.0);
50
19
  });
51
20
 
52
- it('returns amber when isUncertain', () => {
53
- const p: TypeSharedPluralityWire = { isShared: true, isUncertain: true };
54
- expect(pluralityColor(p, false)).toBe('#f59e0b');
21
+ it('returns reduced opacity for partial', () => {
22
+ expect(partitiveRelationStyle('partial', false).opacity).toBe(0.6);
55
23
  });
56
24
 
57
- it('returns dark-mode variants', () => {
58
- expect(pluralityColor({ isShared: true, isUncertain: false }, true)).toBe('#60a5fa');
59
- expect(pluralityColor({ isShared: true, isUncertain: true }, true)).toBe('#fbbf24');
25
+ it('uses teal badge class regardless of completeness', () => {
26
+ expect(partitiveRelationStyle('complete', false).badgeClass).toBe('badge-teal');
27
+ expect(partitiveRelationStyle('partial', false).badgeClass).toBe('badge-teal');
60
28
  });
61
29
  });
62
30
 
@@ -69,14 +37,4 @@ describe('partitive-relation-styling', () => {
69
37
  expect(completenessLabel('partial')).toBe('Partial');
70
38
  });
71
39
  });
72
-
73
- describe('certaintyLabel', () => {
74
- it('labels confirmed', () => {
75
- expect(certaintyLabel('confirmed')).toBe('Confirmed');
76
- });
77
-
78
- it('labels possible', () => {
79
- expect(certaintyLabel('possible')).toBe('Possible');
80
- });
81
- });
82
40
  });
@@ -21,89 +21,79 @@ function extractPartitiveRelations(
21
21
  return `${uriBase}/${reg}/concept/${id}`;
22
22
  };
23
23
 
24
- const resolvePlurality = (plurality: any) => {
25
- if (!plurality) return null;
26
- const isShared = plurality['gl:isShared'] ?? plurality.is_shared;
27
- if (typeof isShared !== 'boolean') return null;
28
- const isUncertain = plurality['gl:isUncertain'] ?? plurality.is_uncertain ?? false;
29
- const sharedTypeRef = plurality['gl:sharedType'] ?? plurality.shared_type;
30
- let sharedType = null;
31
- if (sharedTypeRef) {
32
- const s = sharedTypeRef['gl:source'] ?? sharedTypeRef.source;
33
- const i = sharedTypeRef['gl:id'] ?? sharedTypeRef.id;
34
- if (s && i) sharedType = `${s}:${i}`;
35
- }
36
- return { isShared, isUncertain, sharedType };
37
- };
38
-
39
24
  for (const rel of concept['gl:partitiveRelations'] || []) {
40
25
  const comprehensive = resolveConceptUri(rel['gl:comprehensive']);
41
26
  if (!comprehensive) continue;
27
+
42
28
  const partitives = (rel['gl:hasPartitive'] || [])
43
29
  .map((member: any) => {
44
- const ref = member['gl:ref'] || member;
45
- const uri = resolveConceptUri(ref);
30
+ const memberRef = member['gl:ref'] || member;
31
+ const uri = resolveConceptUri(memberRef);
46
32
  if (!uri || uri === sourceUri) return null;
47
- const certainty = member['gl:certainty'] || 'confirmed';
48
- if (certainty !== 'confirmed' && certainty !== 'possible') {
33
+ const multiplicity = member['gl:multiplicity'] || 'compulsory';
34
+ const isDelimiting = member['gl:isDelimiting'] === true;
35
+ if (!isValidMultiplicity(multiplicity)) {
49
36
  throw new Error(
50
- `Invalid partitive member certainty: "${certainty}". Allowed: confirmed, possible`,
37
+ `Invalid partitive member multiplicity: "${multiplicity}". Allowed: compulsory, optional, compulsory_multiple, optional_multiple, compulsory_at_least_one`,
51
38
  );
52
39
  }
53
- return { uri, certainty };
40
+ return { uri, multiplicity, isDelimiting };
54
41
  })
55
- .filter((p: any): p is { uri: string; certainty: 'confirmed' | 'possible' } => p !== null);
42
+ .filter((p: any): p is { uri: string; multiplicity: 'compulsory' | 'optional' | 'compulsory_multiple' | 'optional_multiple' | 'compulsory_at_least_one'; isDelimiting: boolean } => p !== null);
43
+
56
44
  if (partitives.length === 0) continue;
45
+
57
46
  const completeness = rel['gl:completeness'] || 'complete';
58
47
  if (completeness !== 'complete' && completeness !== 'partial') {
59
48
  throw new Error(
60
49
  `Invalid partitive relation completeness: "${completeness}". Allowed: complete, partial`,
61
50
  );
62
51
  }
52
+
63
53
  relations.push({
64
54
  source: sourceUri,
65
55
  comprehensive,
66
56
  partitives,
67
57
  completeness,
68
- plurality: resolvePlurality(rel['gl:hasPlurality']) as PartitiveRelationWire['plurality'],
69
- criterion: rel['gl:criterion'],
70
58
  register: registerId,
71
59
  });
72
60
  }
73
61
  return relations;
74
62
  }
75
63
 
64
+ function isValidMultiplicity(value: unknown): value is 'compulsory' | 'optional' | 'compulsory_multiple' | 'optional_multiple' | 'compulsory_at_least_one' {
65
+ return ['compulsory', 'optional', 'compulsory_multiple', 'optional_multiple', 'compulsory_at_least_one'].includes(value as string);
66
+ }
67
+
76
68
  describe('extractPartitiveRelations', () => {
77
69
  const uriBase = 'https://example.org';
78
70
  const urnMap = new Map([['urn:vim:pub:v:2:2012', 'vim-2012']]);
79
71
  const registerId = 'vim-2012';
80
- const sourceUri = `${uriBase}/${registerId}/concept/2.9`;
72
+ const sourceUri = `${uriBase}/${registerId}/concept/1.3`;
81
73
 
82
- it('extracts a complete relation with two confirmed members', () => {
74
+ it('extracts a complete relation with two compulsory members', () => {
83
75
  const concept = {
84
76
  '@id': sourceUri,
85
77
  'gl:partitiveRelations': [
86
78
  {
87
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
79
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
88
80
  'gl:hasPartitive': [
89
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
90
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.26' } },
81
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
82
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' } },
91
83
  ],
92
84
  'gl:completeness': 'complete',
93
- 'gl:criterion': { eng: 'measurement result composition' },
94
85
  },
95
86
  ],
96
87
  };
97
88
 
98
89
  const result = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
99
90
  expect(result).toHaveLength(1);
100
- expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/2.9`);
91
+ expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/1.3`);
101
92
  expect(result[0].partitives).toEqual([
102
- { uri: `${uriBase}/${registerId}/concept/2.10`, certainty: 'confirmed' },
103
- { uri: `${uriBase}/${registerId}/concept/2.26`, certainty: 'confirmed' },
93
+ { uri: `${uriBase}/${registerId}/concept/1.4`, multiplicity: 'compulsory', isDelimiting: false },
94
+ { uri: `${uriBase}/${registerId}/concept/1.5`, multiplicity: 'compulsory', isDelimiting: false },
104
95
  ]);
105
96
  expect(result[0].completeness).toBe('complete');
106
- expect(result[0].criterion).toEqual({ eng: 'measurement result composition' });
107
97
  });
108
98
 
109
99
  it('defaults completeness to complete when omitted', () => {
@@ -111,9 +101,9 @@ describe('extractPartitiveRelations', () => {
111
101
  '@id': sourceUri,
112
102
  'gl:partitiveRelations': [
113
103
  {
114
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
104
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
115
105
  'gl:hasPartitive': [
116
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
106
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
117
107
  ],
118
108
  },
119
109
  ],
@@ -139,7 +129,7 @@ describe('extractPartitiveRelations', () => {
139
129
  expect(rel.completeness).toBe('partial');
140
130
  });
141
131
 
142
- it('resolves plurality block (isShared + sharedType)', () => {
132
+ it('reads per-member multiplicity + isDelimiting', () => {
143
133
  const concept = {
144
134
  '@id': sourceUri,
145
135
  'gl:partitiveRelations': [
@@ -147,57 +137,19 @@ describe('extractPartitiveRelations', () => {
147
137
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
148
138
  'gl:hasPartitive': [
149
139
  { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
150
- ],
151
- 'gl:hasPlurality': {
152
- 'gl:isShared': true,
153
- 'gl:isUncertain': false,
154
- 'gl:sharedType': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.99' },
155
- },
156
- },
157
- ],
158
- };
159
- const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
160
- expect(rel.plurality).toEqual({
161
- isShared: true,
162
- isUncertain: false,
163
- sharedType: 'urn:vim:pub:v:2:2012:1.99',
164
- });
165
- });
166
-
167
- it('returns null plurality when isShared missing', () => {
168
- const concept = {
169
- '@id': sourceUri,
170
- 'gl:partitiveRelations': [
171
- {
172
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
173
- 'gl:hasPartitive': [
174
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
175
- ],
176
- 'gl:hasPlurality': { 'gl:isUncertain': true },
177
- },
178
- ],
179
- };
180
- const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
181
- expect(rel.plurality).toBeNull();
182
- });
183
-
184
- it('preserves per-member certainty', () => {
185
- const concept = {
186
- '@id': sourceUri,
187
- 'gl:partitiveRelations': [
188
- {
189
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
190
- 'gl:hasPartitive': [
191
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
192
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:certainty': 'possible' },
140
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:multiplicity': 'optional' },
141
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.6' }, 'gl:multiplicity': 'compulsory_multiple' },
142
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.7' }, 'gl:multiplicity': 'compulsory_at_least_one', 'gl:isDelimiting': true },
193
143
  ],
194
144
  },
195
145
  ],
196
146
  };
197
147
  const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
198
148
  expect(rel.partitives).toEqual([
199
- { uri: `${uriBase}/${registerId}/concept/1.4`, certainty: 'confirmed' },
200
- { uri: `${uriBase}/${registerId}/concept/1.5`, certainty: 'possible' },
149
+ { uri: `${uriBase}/${registerId}/concept/1.4`, multiplicity: 'compulsory', isDelimiting: false },
150
+ { uri: `${uriBase}/${registerId}/concept/1.5`, multiplicity: 'optional', isDelimiting: false },
151
+ { uri: `${uriBase}/${registerId}/concept/1.6`, multiplicity: 'compulsory_multiple', isDelimiting: false },
152
+ { uri: `${uriBase}/${registerId}/concept/1.7`, multiplicity: 'compulsory_at_least_one', isDelimiting: true },
201
153
  ]);
202
154
  });
203
155
 
@@ -217,19 +169,19 @@ describe('extractPartitiveRelations', () => {
217
169
  expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*completeness.*closed/);
218
170
  });
219
171
 
220
- it('throws on invalid member certainty', () => {
172
+ it('throws on invalid multiplicity', () => {
221
173
  const concept = {
222
174
  '@id': sourceUri,
223
175
  'gl:partitiveRelations': [
224
176
  {
225
177
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
226
178
  'gl:hasPartitive': [
227
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:certainty': 'maybe' },
179
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:multiplicity': 'maybe' },
228
180
  ],
229
181
  },
230
182
  ],
231
183
  };
232
- expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*certainty.*maybe/);
184
+ expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*multiplicity.*maybe/);
233
185
  });
234
186
 
235
187
  it('skips a relation whose comprehensive cannot be resolved', () => {
@@ -252,7 +204,7 @@ describe('extractPartitiveRelations', () => {
252
204
  '@id': sourceUri,
253
205
  'gl:partitiveRelations': [
254
206
  {
255
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
207
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
256
208
  'gl:hasPartitive': [{ 'gl:ref': { 'gl:id': '1.4' } }],
257
209
  },
258
210
  ],
@@ -265,17 +217,17 @@ describe('extractPartitiveRelations', () => {
265
217
  '@id': sourceUri,
266
218
  'gl:partitiveRelations': [
267
219
  {
268
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
220
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
269
221
  'gl:hasPartitive': [
270
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
271
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' } },
222
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
223
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' } },
272
224
  ],
273
225
  },
274
226
  ],
275
227
  };
276
228
  const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
277
229
  expect(rel.partitives).toHaveLength(1);
278
- expect(rel.partitives[0].uri).toBe(`${uriBase}/${registerId}/concept/2.10`);
230
+ expect(rel.partitives[0].uri).toBe(`${uriBase}/${registerId}/concept/1.4`);
279
231
  });
280
232
 
281
233
  it('returns empty array when concept has no partitiveRelations', () => {
@@ -0,0 +1,8 @@
1
+ // Type shim for the .mjs script — vitest needs declared types.
2
+ // The actual functions are imported by value (see below).
3
+ declare module '../../../scripts/migrate-v1-to-v3.mjs' {
4
+ export function isV1PartitiveRelation(rel: unknown): boolean;
5
+ export function migrateRelation(rel: unknown): unknown;
6
+ export function migrateConceptData(data: unknown): unknown;
7
+ export function isAlreadyMigrated(content: unknown): boolean;
8
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ // .mjs scripts have no type declarations. The .d.mts shim provides them.
3
+ import {
4
+ isV1PartitiveRelation,
5
+ migrateRelation,
6
+ migrateConceptData,
7
+ isAlreadyMigrated,
8
+ } from '../../../scripts/migrate-v1-to-v3.mjs';
9
+
10
+ describe('migrate-v1-to-v3 pure conversion', () => {
11
+ describe('isV1PartitiveRelation', () => {
12
+ it('returns true for v1 shape (has parts array)', () => {
13
+ expect(isV1PartitiveRelation({ comprehensive: {}, parts: [] })).toBe(true);
14
+ });
15
+
16
+ it('returns false for v3 shape (has partitives array)', () => {
17
+ expect(isV1PartitiveRelation({ comprehensive: {}, partitives: [] })).toBe(false);
18
+ });
19
+
20
+ it('returns falsy for null/undefined', () => {
21
+ expect(isV1PartitiveRelation(null)).toBeFalsy();
22
+ expect(isV1PartitiveRelation(undefined)).toBeFalsy();
23
+ });
24
+ });
25
+
26
+ describe('migrateRelation', () => {
27
+ it('converts v1 → v3 fields', () => {
28
+ const v1 = {
29
+ comprehensive: { source: 'urn:test', id: '1.3' },
30
+ parts: [
31
+ { source: 'urn:test', id: '1.4' },
32
+ { source: 'urn:test', id: '1.5' },
33
+ ],
34
+ enumeration: 'closed',
35
+ };
36
+ const v3 = migrateRelation(v1) as null | { comprehensive: { source: string; id: string }; completeness: string; partitives: Array<{ ref: { source: string; id: string }; certainty: string }> };
37
+ expect(v3).not.toBeNull();
38
+ expect(v3!.comprehensive).toEqual({ source: 'urn:test', id: '1.3' });
39
+ expect(v3!.completeness).toBe('complete');
40
+ expect(v3!.partitives).toHaveLength(2);
41
+ expect(v3!.partitives[0].ref).toEqual({ source: 'urn:test', id: '1.4' });
42
+ expect(v3!.partitives[1].ref).toEqual({ source: 'urn:test', id: '1.5' });
43
+ });
44
+
45
+ it('maps v1 enumeration: open → v3 completeness: partial', () => {
46
+ const v3 = migrateRelation({
47
+ comprehensive: { source: 'urn:test', id: '1.3' },
48
+ parts: [{ source: 'urn:test', id: '1.4' }, { source: 'urn:test', id: '1.5' }],
49
+ enumeration: 'open',
50
+ }) as null | { completeness: string };
51
+ expect(v3).not.toBeNull();
52
+ expect(v3!.completeness).toBe('partial');
53
+ });
54
+
55
+ it('drops v1 markers (multiplicity carries that info now)', () => {
56
+ const v3 = migrateRelation({
57
+ comprehensive: {}, parts: [{ source: 'x', id: 'a' }, { source: 'x', id: 'b' }],
58
+ markers: ['double', 'dashed'],
59
+ });
60
+ expect(v3).not.toHaveProperty('markers');
61
+ });
62
+
63
+ it('preserves v1 content if present (forward-compat)', () => {
64
+ const v3 = migrateRelation({
65
+ comprehensive: {}, parts: [{ source: 'x', id: 'a' }, { source: 'x', id: 'b' }],
66
+ content: 'some prose',
67
+ }) as null | { content?: string };
68
+ expect(v3).not.toBeNull();
69
+ expect(v3!.content).toBe('some prose');
70
+ });
71
+
72
+ it('returns null for already-migrated relation (v3 shape)', () => {
73
+ expect(migrateRelation({
74
+ comprehensive: {},
75
+ partitives: [{ ref: {}, certainty: 'confirmed' }],
76
+ })).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe('migrateConceptData', () => {
81
+ it('returns null for empty partitive_relations', () => {
82
+ expect(migrateConceptData({ partitive_relations: [] })).toBeNull();
83
+ });
84
+
85
+ it('migrates a concept with multiple relations', () => {
86
+ const v1Data = {
87
+ other_field: 'preserved',
88
+ partitive_relations: [
89
+ { comprehensive: {}, parts: [{ source: 'x', id: 'a' }, { source: 'x', id: 'b' }] },
90
+ { comprehensive: {}, parts: [{ source: 'x', id: 'c' }, { source: 'x', id: 'd' }] },
91
+ ],
92
+ };
93
+ const v3 = migrateConceptData(v1Data) as null | { other_field: string; partitive_relations: Array<{ partitives: Array<{ ref: { source: string; id: string } }> }> };
94
+ expect(v3!.other_field).toBe('preserved');
95
+ expect(v3!.partitive_relations).toHaveLength(2);
96
+ expect(v3!.partitive_relations[0].partitives[0].ref).toEqual({ source: 'x', id: 'a' });
97
+ expect(v3!.partitive_relations[1].partitives[1].ref).toEqual({ source: 'x', id: 'd' });
98
+ });
99
+ });
100
+
101
+ describe('isAlreadyMigrated', () => {
102
+ it('detects v3 (partitives array present)', () => {
103
+ expect(isAlreadyMigrated({ data: { partitive_relations: [{ partitives: [] }] } })).toBe(true);
104
+ });
105
+
106
+ it('returns false for v1 (parts but no partitives)', () => {
107
+ expect(isAlreadyMigrated({ data: { partitive_relations: [{ parts: [] }] } })).toBe(false);
108
+ });
109
+
110
+ it('returns false for empty data', () => {
111
+ expect(isAlreadyMigrated({ data: {} })).toBe(false);
112
+ expect(isAlreadyMigrated(null)).toBe(false);
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ PARTITIVE_MULTIPLICITY,
4
+ PARTITIVE_MULTIPLICITY_VALUES,
5
+ isPartitiveMultiplicity,
6
+ multiplicityDefinition,
7
+ rakeStrokeStyle,
8
+ multiplicityFromCertainty,
9
+ NORMAL_STROKE_WIDTH,
10
+ DELIMITING_STROKE_WIDTH,
11
+ type PartitiveMultiplicity,
12
+ } from '../../utils/partitive-multiplicity';
13
+
14
+ describe('partitive-multiplicity registry', () => {
15
+ it('has exactly 5 ISO 704:2022 multiplicity values', () => {
16
+ expect(PARTITIVE_MULTIPLICITY_VALUES).toEqual([
17
+ 'compulsory',
18
+ 'optional',
19
+ 'compulsory_multiple',
20
+ 'optional_multiple',
21
+ 'compulsory_at_least_one',
22
+ ]);
23
+ });
24
+
25
+ it('compulsory: 1 line, solid', () => {
26
+ expect(multiplicityDefinition('compulsory')).toEqual({
27
+ lines: 1, pattern: 'solid', label: 'Compulsory',
28
+ });
29
+ });
30
+
31
+ it('optional: 1 line, dashed', () => {
32
+ expect(multiplicityDefinition('optional')).toEqual({
33
+ lines: 1, pattern: 'dashed', label: 'Optional',
34
+ });
35
+ });
36
+
37
+ it('compulsory_multiple: 2 lines, solid', () => {
38
+ expect(multiplicityDefinition('compulsory_multiple')).toEqual({
39
+ lines: 2, pattern: 'solid', label: 'Compulsory multiple',
40
+ });
41
+ });
42
+
43
+ it('optional_multiple: 2 lines, dashed', () => {
44
+ expect(multiplicityDefinition('optional_multiple')).toEqual({
45
+ lines: 2, pattern: 'dashed', label: 'Optional multiple',
46
+ });
47
+ });
48
+
49
+ it('compulsory_at_least_one: 2 lines, mixed (one solid + one dashed)', () => {
50
+ expect(multiplicityDefinition('compulsory_at_least_one')).toEqual({
51
+ lines: 2, pattern: 'mixed', label: 'At least one',
52
+ });
53
+ });
54
+
55
+ it('isPartitiveMultiplicity narrows known values', () => {
56
+ expect(isPartitiveMultiplicity('compulsory')).toBe(true);
57
+ expect(isPartitiveMultiplicity('compulsory_at_least_one')).toBe(true);
58
+ expect(isPartitiveMultiplicity('unknown')).toBe(false);
59
+ expect(isPartitiveMultiplicity(null)).toBe(false);
60
+ expect(isPartitiveMultiplicity(42)).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe('rakeStrokeStyle', () => {
65
+ const cases: Array<{
66
+ name: string;
67
+ multiplicity: PartitiveMultiplicity;
68
+ isDelimiting: boolean;
69
+ expected: {
70
+ lineCount: 1 | 2;
71
+ primaryDashed: boolean;
72
+ secondaryDashed: boolean;
73
+ strokeWidth: number;
74
+ };
75
+ }> = [
76
+ // 5 multiplicity × 2 delimiting = 10 variants
77
+ { name: 'compulsory / non-delimiting', multiplicity: 'compulsory', isDelimiting: false, expected: { lineCount: 1, primaryDashed: false, secondaryDashed: false, strokeWidth: NORMAL_STROKE_WIDTH } },
78
+ { name: 'compulsory / delimiting', multiplicity: 'compulsory', isDelimiting: true, expected: { lineCount: 1, primaryDashed: false, secondaryDashed: false, strokeWidth: DELIMITING_STROKE_WIDTH } },
79
+ { name: 'optional / non-delimiting', multiplicity: 'optional', isDelimiting: false, expected: { lineCount: 1, primaryDashed: true, secondaryDashed: true, strokeWidth: NORMAL_STROKE_WIDTH } },
80
+ { name: 'optional / delimiting', multiplicity: 'optional', isDelimiting: true, expected: { lineCount: 1, primaryDashed: true, secondaryDashed: true, strokeWidth: DELIMITING_STROKE_WIDTH } },
81
+ { name: 'compulsory_multiple / non-delimiting', multiplicity: 'compulsory_multiple', isDelimiting: false, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: false, strokeWidth: NORMAL_STROKE_WIDTH } },
82
+ { name: 'compulsory_multiple / delimiting', multiplicity: 'compulsory_multiple', isDelimiting: true, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: false, strokeWidth: DELIMITING_STROKE_WIDTH } },
83
+ { name: 'optional_multiple / non-delimiting', multiplicity: 'optional_multiple', isDelimiting: false, expected: { lineCount: 2, primaryDashed: true, secondaryDashed: true, strokeWidth: NORMAL_STROKE_WIDTH } },
84
+ { name: 'optional_multiple / delimiting', multiplicity: 'optional_multiple', isDelimiting: true, expected: { lineCount: 2, primaryDashed: true, secondaryDashed: true, strokeWidth: DELIMITING_STROKE_WIDTH } },
85
+ { name: 'compulsory_at_least_one / non-delimiting', multiplicity: 'compulsory_at_least_one', isDelimiting: false, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: true, strokeWidth: NORMAL_STROKE_WIDTH } },
86
+ { name: 'compulsory_at_least_one / delimiting', multiplicity: 'compulsory_at_least_one', isDelimiting: true, expected: { lineCount: 2, primaryDashed: false, secondaryDashed: true, strokeWidth: DELIMITING_STROKE_WIDTH } },
87
+ ];
88
+
89
+ for (const c of cases) {
90
+ it(`${c.name} → ${JSON.stringify(c.expected)}`, () => {
91
+ expect(rakeStrokeStyle(c.multiplicity, c.isDelimiting)).toEqual(c.expected);
92
+ });
93
+ }
94
+
95
+ it('delimiting strokeWidth is exactly 3× normal (per ISO 704)', () => {
96
+ expect(DELIMITING_STROKE_WIDTH / NORMAL_STROKE_WIDTH).toBe(3);
97
+ });
98
+ });
99
+
100
+ describe('multiplicityFromCertainty (glossarist 0.4.20 migration)', () => {
101
+ it('confirmed → compulsory', () => {
102
+ expect(multiplicityFromCertainty('confirmed')).toBe('compulsory');
103
+ });
104
+
105
+ it('possible → optional', () => {
106
+ expect(multiplicityFromCertainty('possible')).toBe('optional');
107
+ });
108
+
109
+ it('null / undefined → compulsory (default)', () => {
110
+ expect(multiplicityFromCertainty(null)).toBe('compulsory');
111
+ expect(multiplicityFromCertainty(undefined)).toBe('compulsory');
112
+ });
113
+ });
@@ -166,20 +166,18 @@ interface JsonLdLocalizedConcept {
166
166
 
167
167
  interface JsonLdPartitiveMember {
168
168
  'gl:ref'?: JsonLdRef;
169
+ /** ISO 704:2022 v3 field (native in glossarist 0.4.24). */
170
+ 'gl:multiplicity'?: string;
171
+ /** ISO 704:2022 v3 field (native in glossarist 0.4.24). */
172
+ 'gl:isDelimiting'?: boolean;
173
+ /** Legacy v2 field — still read for backward compat with older datasets. */
169
174
  'gl:certainty'?: string;
170
175
  }
171
176
 
172
- interface JsonLdTypeSharedPlurality {
173
- 'gl:isShared'?: boolean;
174
- 'gl:isUncertain'?: boolean;
175
- 'gl:sharedType'?: JsonLdRef;
176
- }
177
-
178
177
  interface JsonLdPartitiveRelation {
179
178
  'gl:comprehensive'?: JsonLdRef;
180
179
  'gl:hasPartitive'?: JsonLdPartitiveMember[];
181
180
  'gl:completeness'?: string;
182
- 'gl:hasPlurality'?: JsonLdTypeSharedPlurality;
183
181
  'gl:criterion'?: Record<string, string> | string;
184
182
  }
185
183
 
@@ -592,7 +590,16 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
592
590
  const ref = m['gl:ref'] ? mapRefFromJsonLd(m['gl:ref']) : null;
593
591
  if (!ref) return null;
594
592
  const out: Record<string, unknown> = { ref };
595
- if (m['gl:certainty']) out.certainty = m['gl:certainty'];
593
+ // ISO 704:2022 multiplicity + is_delimiting (v3). Fall back to
594
+ // legacy gl:certainty for backward compat.
595
+ if (m['gl:multiplicity']) {
596
+ out.multiplicity = m['gl:multiplicity'];
597
+ } else if (m['gl:certainty']) {
598
+ out.multiplicity = m['gl:certainty'] === 'possible' ? 'optional' : 'compulsory';
599
+ }
600
+ if (m['gl:isDelimiting'] === true) {
601
+ out.is_delimiting = true;
602
+ }
596
603
  return out;
597
604
  })
598
605
  .filter((m): m is Record<string, unknown> => m !== null);
@@ -608,17 +615,6 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
608
615
 
609
616
  if (r['gl:completeness']) out.completeness = r['gl:completeness'];
610
617
 
611
- const plurality = r['gl:hasPlurality'];
612
- if (plurality && typeof plurality['gl:isShared'] === 'boolean') {
613
- const p: Record<string, unknown> = { isShared: plurality['gl:isShared'] };
614
- if (typeof plurality['gl:isUncertain'] === 'boolean') {
615
- p.isUncertain = plurality['gl:isUncertain'];
616
- }
617
- const sharedType = plurality['gl:sharedType'] ? mapRefFromJsonLd(plurality['gl:sharedType']) : null;
618
- if (sharedType) p.sharedType = sharedType;
619
- out.plurality = p;
620
- }
621
-
622
618
  if (r['gl:criterion']) {
623
619
  out.criterion = typeof r['gl:criterion'] === 'string'
624
620
  ? { default: r['gl:criterion'] }