@glossarist/concept-browser 0.7.85 → 0.7.87

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
  });
@@ -13,6 +13,14 @@ function extractPartitiveRelations(
13
13
  const relations: PartitiveRelationWire[] = [];
14
14
  const sourceUri = concept['@id'];
15
15
 
16
+ const LEGACY_MULTIPLICITY_MAP: Record<string, { presence: string; count: string }> = {
17
+ compulsory: { presence: 'required', count: 'exactly_one' },
18
+ optional: { presence: 'optional', count: 'exactly_one' },
19
+ compulsory_multiple: { presence: 'required', count: 'multiple' },
20
+ optional_multiple: { presence: 'optional', count: 'multiple' },
21
+ compulsory_at_least_one: { presence: 'required', count: 'at_least_one' },
22
+ };
23
+
16
24
  const resolveConceptUri = (ref: any): string | null => {
17
25
  const source = ref?.['gl:source'] || ref?.source;
18
26
  const id = ref?.['gl:id'] || ref?.id;
@@ -21,89 +29,96 @@ function extractPartitiveRelations(
21
29
  return `${uriBase}/${reg}/concept/${id}`;
22
30
  };
23
31
 
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}`;
32
+ const resolvePresence = (member: any): 'required' | 'optional' => {
33
+ const p = member['gl:presence'] ?? member.presence;
34
+ if (p === 'required' || p === 'optional') return p as 'required' | 'optional';
35
+ const multiplicity = member['gl:multiplicity'] ?? member.multiplicity;
36
+ if (typeof multiplicity === 'string' && LEGACY_MULTIPLICITY_MAP[multiplicity]) {
37
+ return LEGACY_MULTIPLICITY_MAP[multiplicity].presence as 'required' | 'optional';
38
+ }
39
+ const certainty = member['gl:certainty'] ?? member.certainty;
40
+ return certainty === 'possible' ? 'optional' : 'required';
41
+ };
42
+
43
+ const resolveCount = (member: any): 'exactly_one' | 'at_least_one' | 'multiple' => {
44
+ const c = member['gl:count'] ?? member.count;
45
+ if (c === 'exactly_one' || c === 'at_least_one' || c === 'multiple') {
46
+ return c as 'exactly_one' | 'at_least_one' | 'multiple';
47
+ }
48
+ const multiplicity = member['gl:multiplicity'] ?? member.multiplicity;
49
+ if (typeof multiplicity === 'string' && LEGACY_MULTIPLICITY_MAP[multiplicity]) {
50
+ return LEGACY_MULTIPLICITY_MAP[multiplicity].count as 'exactly_one' | 'at_least_one' | 'multiple';
35
51
  }
36
- return { isShared, isUncertain, sharedType };
52
+ return 'exactly_one';
37
53
  };
38
54
 
39
55
  for (const rel of concept['gl:partitiveRelations'] || []) {
40
56
  const comprehensive = resolveConceptUri(rel['gl:comprehensive']);
41
57
  if (!comprehensive) continue;
58
+
42
59
  const partitives = (rel['gl:hasPartitive'] || [])
43
60
  .map((member: any) => {
44
- const ref = member['gl:ref'] || member;
45
- const uri = resolveConceptUri(ref);
61
+ const memberRef = member['gl:ref'] || member;
62
+ const uri = resolveConceptUri(memberRef);
46
63
  if (!uri || uri === sourceUri) return null;
47
- const certainty = member['gl:certainty'] || 'confirmed';
48
- if (certainty !== 'confirmed' && certainty !== 'possible') {
49
- throw new Error(
50
- `Invalid partitive member certainty: "${certainty}". Allowed: confirmed, possible`,
51
- );
52
- }
53
- return { uri, certainty };
64
+ return {
65
+ uri,
66
+ presence: resolvePresence(member),
67
+ count: resolveCount(member),
68
+ isDelimiting: member['gl:isDelimiting'] === true || member.isDelimiting === true,
69
+ };
54
70
  })
55
- .filter((p: any): p is { uri: string; certainty: 'confirmed' | 'possible' } => p !== null);
71
+ .filter((p: any): p is { uri: string; presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple'; isDelimiting: boolean } => p !== null);
72
+
56
73
  if (partitives.length === 0) continue;
74
+
57
75
  const completeness = rel['gl:completeness'] || 'complete';
58
76
  if (completeness !== 'complete' && completeness !== 'partial') {
59
77
  throw new Error(
60
78
  `Invalid partitive relation completeness: "${completeness}". Allowed: complete, partial`,
61
79
  );
62
80
  }
81
+
63
82
  relations.push({
64
83
  source: sourceUri,
65
84
  comprehensive,
66
85
  partitives,
67
86
  completeness,
68
- plurality: resolvePlurality(rel['gl:hasPlurality']) as PartitiveRelationWire['plurality'],
69
- criterion: rel['gl:criterion'],
70
87
  register: registerId,
71
88
  });
72
89
  }
73
90
  return relations;
74
91
  }
75
92
 
76
- describe('extractPartitiveRelations', () => {
93
+ describe('extractPartitiveRelations (MECE presence × count)', () => {
77
94
  const uriBase = 'https://example.org';
78
95
  const urnMap = new Map([['urn:vim:pub:v:2:2012', 'vim-2012']]);
79
96
  const registerId = 'vim-2012';
80
- const sourceUri = `${uriBase}/${registerId}/concept/2.9`;
97
+ const sourceUri = `${uriBase}/${registerId}/concept/1.3`;
81
98
 
82
- it('extracts a complete relation with two confirmed members', () => {
99
+ it('extracts a complete relation with two required/exactly_one members', () => {
83
100
  const concept = {
84
101
  '@id': sourceUri,
85
102
  'gl:partitiveRelations': [
86
103
  {
87
- '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' },
88
105
  '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' } },
106
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
107
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' } },
91
108
  ],
92
109
  'gl:completeness': 'complete',
93
- 'gl:criterion': { eng: 'measurement result composition' },
94
110
  },
95
111
  ],
96
112
  };
97
113
 
98
114
  const result = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
99
115
  expect(result).toHaveLength(1);
100
- expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/2.9`);
116
+ expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/1.3`);
101
117
  expect(result[0].partitives).toEqual([
102
- { uri: `${uriBase}/${registerId}/concept/2.10`, certainty: 'confirmed' },
103
- { uri: `${uriBase}/${registerId}/concept/2.26`, certainty: 'confirmed' },
118
+ { uri: `${uriBase}/${registerId}/concept/1.4`, presence: 'required', count: 'exactly_one', isDelimiting: false },
119
+ { uri: `${uriBase}/${registerId}/concept/1.5`, presence: 'required', count: 'exactly_one', isDelimiting: false },
104
120
  ]);
105
121
  expect(result[0].completeness).toBe('complete');
106
- expect(result[0].criterion).toEqual({ eng: 'measurement result composition' });
107
122
  });
108
123
 
109
124
  it('defaults completeness to complete when omitted', () => {
@@ -111,9 +126,9 @@ describe('extractPartitiveRelations', () => {
111
126
  '@id': sourceUri,
112
127
  'gl:partitiveRelations': [
113
128
  {
114
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
129
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
115
130
  'gl:hasPartitive': [
116
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
131
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
117
132
  ],
118
133
  },
119
134
  ],
@@ -139,7 +154,7 @@ describe('extractPartitiveRelations', () => {
139
154
  expect(rel.completeness).toBe('partial');
140
155
  });
141
156
 
142
- it('resolves plurality block (isShared + sharedType)', () => {
157
+ it('reads per-member presence + count + isDelimiting (v3 native)', () => {
143
158
  const concept = {
144
159
  '@id': sourceUri,
145
160
  'gl:partitiveRelations': [
@@ -147,57 +162,39 @@ describe('extractPartitiveRelations', () => {
147
162
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
148
163
  'gl:hasPartitive': [
149
164
  { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
165
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:presence': 'optional' },
166
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.6' }, 'gl:count': 'multiple' },
167
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.7' }, 'gl:count': 'at_least_one', 'gl:isDelimiting': true },
150
168
  ],
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
169
  },
157
170
  ],
158
171
  };
159
172
  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();
173
+ expect(rel.partitives).toEqual([
174
+ { uri: `${uriBase}/${registerId}/concept/1.4`, presence: 'required', count: 'exactly_one', isDelimiting: false },
175
+ { uri: `${uriBase}/${registerId}/concept/1.5`, presence: 'optional', count: 'exactly_one', isDelimiting: false },
176
+ { uri: `${uriBase}/${registerId}/concept/1.6`, presence: 'required', count: 'multiple', isDelimiting: false },
177
+ { uri: `${uriBase}/${registerId}/concept/1.7`, presence: 'required', count: 'at_least_one', isDelimiting: true },
178
+ ]);
182
179
  });
183
180
 
184
- it('preserves per-member certainty', () => {
181
+ it('migrates legacy gl:multiplicity into presence + count (one-release compat)', () => {
185
182
  const concept = {
186
183
  '@id': sourceUri,
187
184
  'gl:partitiveRelations': [
188
185
  {
189
186
  'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
190
187
  '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' },
188
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:multiplicity': 'optional_multiple' },
189
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory_at_least_one' },
193
190
  ],
194
191
  },
195
192
  ],
196
193
  };
197
194
  const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
198
195
  expect(rel.partitives).toEqual([
199
- { uri: `${uriBase}/${registerId}/concept/1.4`, certainty: 'confirmed' },
200
- { uri: `${uriBase}/${registerId}/concept/1.5`, certainty: 'possible' },
196
+ { uri: `${uriBase}/${registerId}/concept/1.4`, presence: 'optional', count: 'multiple', isDelimiting: false },
197
+ { uri: `${uriBase}/${registerId}/concept/1.5`, presence: 'required', count: 'at_least_one', isDelimiting: false },
201
198
  ]);
202
199
  });
203
200
 
@@ -217,21 +214,6 @@ describe('extractPartitiveRelations', () => {
217
214
  expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*completeness.*closed/);
218
215
  });
219
216
 
220
- it('throws on invalid member certainty', () => {
221
- const concept = {
222
- '@id': sourceUri,
223
- 'gl:partitiveRelations': [
224
- {
225
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
226
- 'gl:hasPartitive': [
227
- { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:certainty': 'maybe' },
228
- ],
229
- },
230
- ],
231
- };
232
- expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*certainty.*maybe/);
233
- });
234
-
235
217
  it('skips a relation whose comprehensive cannot be resolved', () => {
236
218
  const concept = {
237
219
  '@id': sourceUri,
@@ -252,7 +234,7 @@ describe('extractPartitiveRelations', () => {
252
234
  '@id': sourceUri,
253
235
  'gl:partitiveRelations': [
254
236
  {
255
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
237
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
256
238
  'gl:hasPartitive': [{ 'gl:ref': { 'gl:id': '1.4' } }],
257
239
  },
258
240
  ],
@@ -265,17 +247,17 @@ describe('extractPartitiveRelations', () => {
265
247
  '@id': sourceUri,
266
248
  'gl:partitiveRelations': [
267
249
  {
268
- 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
250
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
269
251
  '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' } },
252
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
253
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' } },
272
254
  ],
273
255
  },
274
256
  ],
275
257
  };
276
258
  const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
277
259
  expect(rel.partitives).toHaveLength(1);
278
- expect(rel.partitives[0].uri).toBe(`${uriBase}/${registerId}/concept/2.10`);
260
+ expect(rel.partitives[0].uri).toBe(`${uriBase}/${registerId}/concept/1.4`);
279
261
  });
280
262
 
281
263
  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,137 @@
1
+ /**
2
+ * MECE refactor of partitive-multiplicity (ISO 704:2022).
3
+ *
4
+ * Types + enums come from glossarist-js (the SSOT for the model).
5
+ * The render-ready stroke style is the only browser-specific concern;
6
+ * its truth table is pinned here so future rendering edits stay
7
+ * aligned with the spec.
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import {
11
+ PARTITIVE_PRESENCE,
12
+ PARTITIVE_COUNT,
13
+ isPartitivePresence,
14
+ isPartitiveCount,
15
+ presenceLabel,
16
+ countLabel,
17
+ rakeStrokeStyle,
18
+ partitiveMultiplicityName,
19
+ NORMAL_STROKE_WIDTH,
20
+ DELIMITING_STROKE_WIDTH,
21
+ type PartitivePresence,
22
+ type PartitiveCount,
23
+ } from '../../utils/partitive-multiplicity';
24
+
25
+ describe('presence axis (re-exported from glossarist)', () => {
26
+ it('has exactly 2 values: required, optional', () => {
27
+ expect([...PARTITIVE_PRESENCE.VALUES]).toEqual(['required', 'optional']);
28
+ });
29
+
30
+ it('isPartitivePresence narrows known values', () => {
31
+ expect(isPartitivePresence('required')).toBe(true);
32
+ expect(isPartitivePresence('optional')).toBe(true);
33
+ expect(isPartitivePresence('unknown')).toBe(false);
34
+ expect(isPartitivePresence(null)).toBe(false);
35
+ expect(isPartitivePresence(42)).toBe(false);
36
+ });
37
+
38
+ it('presenceLabel returns human-readable labels', () => {
39
+ expect(presenceLabel('required')).toBe('Required');
40
+ expect(presenceLabel('optional')).toBe('Optional');
41
+ });
42
+ });
43
+
44
+ describe('count axis (re-exported from glossarist)', () => {
45
+ it('has exactly 3 values: exactly_one, at_least_one, multiple', () => {
46
+ expect([...PARTITIVE_COUNT.VALUES]).toEqual(['exactly_one', 'at_least_one', 'multiple']);
47
+ });
48
+
49
+ it('isPartitiveCount narrows known values', () => {
50
+ expect(isPartitiveCount('exactly_one')).toBe(true);
51
+ expect(isPartitiveCount('at_least_one')).toBe(true);
52
+ expect(isPartitiveCount('multiple')).toBe(true);
53
+ expect(isPartitiveCount('unknown')).toBe(false);
54
+ expect(isPartitiveCount(null)).toBe(false);
55
+ });
56
+
57
+ it('countLabel returns human-readable labels', () => {
58
+ expect(countLabel('exactly_one')).toBe('Exactly one');
59
+ expect(countLabel('at_least_one')).toBe('At least one');
60
+ expect(countLabel('multiple')).toBe('Multiple');
61
+ });
62
+ });
63
+
64
+ describe('partitiveMultiplicityName (glossarist SSOT)', () => {
65
+ it('maps the 5 valid combinations to ISO 704 names', () => {
66
+ expect(partitiveMultiplicityName('required', 'exactly_one')).toBe('compulsory');
67
+ expect(partitiveMultiplicityName('optional', 'exactly_one')).toBe('optional');
68
+ expect(partitiveMultiplicityName('required', 'multiple')).toBe('compulsory_multiple');
69
+ expect(partitiveMultiplicityName('optional', 'multiple')).toBe('optional_multiple');
70
+ expect(partitiveMultiplicityName('required', 'at_least_one')).toBe('compulsory_at_least_one');
71
+ });
72
+
73
+ it('rejects the vacuous (optional, at_least_one) combination', () => {
74
+ expect(() => partitiveMultiplicityName('optional', 'at_least_one'))
75
+ .toThrow(/collapses to optional \+ multiple/);
76
+ });
77
+ });
78
+
79
+ describe('rakeStrokeStyle (MECE rendering truth table)', () => {
80
+ const presenceCases: PartitivePresence[] = ['required', 'optional'];
81
+ const countCases: PartitiveCount[] = ['exactly_one', 'at_least_one', 'multiple'];
82
+ const delimitingCases = [false, true];
83
+
84
+ for (const presence of presenceCases) {
85
+ for (const count of countCases) {
86
+ for (const isDelimiting of delimitingCases) {
87
+ const expectedWidth = isDelimiting ? DELIMITING_STROKE_WIDTH : NORMAL_STROKE_WIDTH;
88
+ const expectedPrimaryDashed = presence === 'optional';
89
+ const expectedLineCount: 1 | 2 = count === 'exactly_one' ? 1 : 2;
90
+ // When lineCount=1, secondaryDashed is unused (no second line).
91
+ // Implementation choice: set it to false as the default.
92
+ // When lineCount=2:
93
+ // - at_least_one: secondary always dashed ("possibly more")
94
+ // - multiple: secondary mirrors primary
95
+ const expectedSecondaryDashed = expectedLineCount === 1
96
+ ? false
97
+ : count === 'at_least_one'
98
+ ? true
99
+ : expectedPrimaryDashed;
100
+
101
+ it(`${presence} × ${count}${isDelimiting ? ' / delimiting' : ''}`, () => {
102
+ expect(rakeStrokeStyle(presence, count, isDelimiting)).toEqual({
103
+ lineCount: expectedLineCount,
104
+ primaryDashed: expectedPrimaryDashed,
105
+ secondaryDashed: expectedSecondaryDashed,
106
+ strokeWidth: expectedWidth,
107
+ });
108
+ });
109
+ }
110
+ }
111
+ }
112
+
113
+ it('delimiting strokeWidth is 3× normal (per ISO 704 convention)', () => {
114
+ expect(DELIMITING_STROKE_WIDTH / NORMAL_STROKE_WIDTH).toBe(3);
115
+ });
116
+
117
+ it('exactly_one always produces a single line, regardless of presence', () => {
118
+ for (const presence of presenceCases) {
119
+ const style = rakeStrokeStyle(presence, 'exactly_one', false);
120
+ expect(style.lineCount).toBe(1);
121
+ expect(style.primaryDashed).toBe(presence === 'optional');
122
+ }
123
+ });
124
+
125
+ it('at_least_one differs from multiple by secondary line dash pattern', () => {
126
+ for (const presence of presenceCases) {
127
+ const atleastOne = rakeStrokeStyle(presence, 'at_least_one', false);
128
+ const multiple = rakeStrokeStyle(presence, 'multiple', false);
129
+ expect(atleastOne.lineCount).toBe(2);
130
+ expect(multiple.lineCount).toBe(2);
131
+ // at_least_one: secondary always dashed (possibly more)
132
+ expect(atleastOne.secondaryDashed).toBe(true);
133
+ // multiple: secondary matches primary
134
+ expect(multiple.secondaryDashed).toBe(multiple.primaryDashed);
135
+ }
136
+ });
137
+ });