@glossarist/concept-browser 0.7.84 → 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.
@@ -3,26 +3,26 @@ import { mount } from '@vue/test-utils';
3
3
  import PartitiveRelationDiagram, {
4
4
  type PartitiveMemberLabeled,
5
5
  } from '../../components/PartitiveRelationDiagram.vue';
6
+ import { rakeStrokeStyle, type PartitiveMultiplicity } from '../../utils/partitive-multiplicity';
6
7
 
7
- const members = (n: number, certainty: 'confirmed' | 'possible' = 'confirmed'): PartitiveMemberLabeled[] =>
8
+ const members = (
9
+ n: number,
10
+ opts: { multiplicity?: PartitiveMultiplicity; isDelimiting?: boolean } = {},
11
+ ): PartitiveMemberLabeled[] =>
8
12
  Array.from({ length: n }, (_, i) => ({
9
13
  uri: `https://example.org/test/concept/1.${i + 1}`,
10
14
  label: `1.${i + 1}`,
11
- certainty,
15
+ multiplicity: opts.multiplicity ?? 'compulsory',
16
+ isDelimiting: opts.isDelimiting ?? false,
12
17
  }));
13
18
 
14
- function svgLinesOf(wrapper: ReturnType<typeof mount>) {
15
- return wrapper.findAll('line');
16
- }
17
-
18
- describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
19
+ describe('PartitiveRelationDiagram — ISO 704:2022 rake rendering', () => {
19
20
  it('renders comprehensive + N partitive nodes as text', () => {
20
21
  const w = mount(PartitiveRelationDiagram, {
21
22
  props: {
22
23
  comprehensiveLabel: '1.3',
23
24
  partitives: members(3),
24
25
  completeness: 'complete',
25
- plurality: null,
26
26
  },
27
27
  });
28
28
  const texts = w.findAll('text').map(t => t.text());
@@ -38,133 +38,108 @@ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
38
38
  comprehensiveLabel: '1.3',
39
39
  partitives: members(2),
40
40
  completeness: 'complete',
41
- plurality: null,
42
41
  criterion: 'physical structure',
43
42
  },
44
43
  });
45
44
  expect(w.findAll('text').map(t => t.text())).toContain('physical structure');
46
45
  });
47
46
 
48
- describe('line variant by plurality', () => {
49
- it('single solid line when no plurality (3 lines: stem + spine + 2 drops = 4 actually)', () => {
47
+ describe('per-member multiplicity line rendering', () => {
48
+ it('compulsory: stem + spine + 1 solid line per drop', () => {
50
49
  const w = mount(PartitiveRelationDiagram, {
51
50
  props: {
52
- comprehensiveLabel: '1.3',
51
+ comprehensiveLabel: '0',
53
52
  partitives: members(2),
54
53
  completeness: 'complete',
55
- plurality: null,
56
54
  },
57
55
  });
58
- // stem (1) + spine (1) + drops (2) = 4 single lines
59
- expect(svgLinesOf(w)).toHaveLength(4);
60
- // none should be dashed
61
- const dashed = svgLinesOf(w).filter(l => l.attributes('stroke-dasharray'));
62
- expect(dashed).toHaveLength(0);
56
+ // stem (1) + spine (1) + 1 drop line per member × 2 members = 4
57
+ expect(w.findAll('line')).toHaveLength(4);
63
58
  });
64
59
 
65
- it('close-set double solid lines when isShared && !isUncertain', () => {
60
+ it('compulsory_multiple: each drop has 2 lines', () => {
66
61
  const w = mount(PartitiveRelationDiagram, {
67
62
  props: {
68
- comprehensiveLabel: '1.3',
69
- partitives: members(2),
63
+ comprehensiveLabel: '0',
64
+ partitives: members(2, { multiplicity: 'compulsory_multiple' }),
70
65
  completeness: 'complete',
71
- plurality: { isShared: true, isUncertain: false },
72
66
  },
73
67
  });
74
- // stem doubles (2) + spine doubles (2) + drops (2, single) = 6 lines
75
- expect(svgLinesOf(w)).toHaveLength(6);
76
- const dashed = svgLinesOf(w).filter(l => l.attributes('stroke-dasharray'));
77
- expect(dashed).toHaveLength(0);
68
+ // stem + spine + 2 drop lines per member × 2 = 6
69
+ expect(w.findAll('line')).toHaveLength(6);
78
70
  });
79
71
 
80
- it('one solid + one dashed when isShared && isUncertain', () => {
72
+ it('optional: drop is dashed', () => {
81
73
  const w = mount(PartitiveRelationDiagram, {
82
74
  props: {
83
- comprehensiveLabel: '1.3',
84
- partitives: members(2),
75
+ comprehensiveLabel: '0',
76
+ partitives: members(2, { multiplicity: 'optional' }),
85
77
  completeness: 'complete',
86
- plurality: { isShared: true, isUncertain: true },
87
78
  },
88
79
  });
89
- // stem (2: 1 solid + 1 dashed) + spine (2: 1 solid + 1 dashed) + drops (2, single solid) = 6
90
- const lines = svgLinesOf(w);
91
- expect(lines).toHaveLength(6);
80
+ // stem + spine (frame, not dashed) + 2 drops (dashed)
81
+ const lines = w.findAll('line');
92
82
  const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
93
- // exactly 2 dashed lines (the second stem + the second spine)
94
83
  expect(dashed).toHaveLength(2);
95
84
  });
96
- });
97
85
 
98
- describe('completeness: partial extends spine', () => {
99
- it('extends spine past last tooth when completeness=partial', () => {
86
+ it('optional_multiple: each drop has 2 dashed lines', () => {
100
87
  const w = mount(PartitiveRelationDiagram, {
101
88
  props: {
102
- comprehensiveLabel: '1.3',
103
- partitives: members(2),
104
- completeness: 'partial',
105
- plurality: null,
89
+ comprehensiveLabel: '0',
90
+ partitives: members(2, { multiplicity: 'optional_multiple' }),
91
+ completeness: 'complete',
106
92
  },
107
93
  });
108
- const lines = svgLinesOf(w);
109
- // stem + spine + 2 drops = 4 lines (same count, but spine is longer)
110
- expect(lines).toHaveLength(4);
111
- // The spine line's x2 should be > the last partitive's x
112
- const lastMember = w.props('partitives')[1];
113
- // visual assertion: the spine line's x2 is greater than the second
114
- // partitive's slot x. We can't compute slot exactly without internals,
115
- // but we know PARTIAL_TAIL (32) is added.
116
- const spineLine = lines.find(l => {
117
- const y1 = l.attributes('y1');
118
- const y2 = l.attributes('y2');
119
- return y1 && y2 && y1 === y2; // horizontal
120
- });
121
- expect(spineLine).toBeDefined();
122
- const x2 = Number(spineLine!.attributes('x2'));
123
- // last member x ≈ PADDING_X + NODE_W * 1.5 + GAP_X = 24 + 180 + 16 = 220
124
- // spine end with PARTIAL_TAIL = 220 + 32 = 252
125
- expect(x2).toBeGreaterThan(220);
126
- void lastMember;
94
+ const lines = w.findAll('line');
95
+ const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
96
+ expect(dashed).toHaveLength(4); // 2 members × 2 dashed lines each
127
97
  });
128
- });
129
98
 
130
- describe('per-member certainty', () => {
131
- it('renders possible members with dashed border', () => {
132
- const possibleMembers: PartitiveMemberLabeled[] = [
133
- { uri: 'u1', label: '1', certainty: 'confirmed' },
134
- { uri: 'u2', label: '2', certainty: 'possible' },
135
- ];
99
+ it('compulsory_at_least_one: each drop has 1 solid + 1 dashed line', () => {
136
100
  const w = mount(PartitiveRelationDiagram, {
137
101
  props: {
138
102
  comprehensiveLabel: '0',
139
- partitives: possibleMembers,
103
+ partitives: members(2, { multiplicity: 'compulsory_at_least_one' }),
140
104
  completeness: 'complete',
141
- plurality: null,
142
105
  },
143
106
  });
144
- // Find the rect for the 'possible' member (the 2nd node)
145
- const rects = w.findAll('rect');
146
- // 1 comp + 2 partitive = 3 rects
147
- expect(rects).toHaveLength(3);
148
- // The possible member's rect should have a dashed border
149
- const possibleRect = rects[2];
150
- expect(possibleRect.attributes('stroke-dasharray')).toBeTruthy();
107
+ const lines = w.findAll('line');
108
+ // stem + spine (solid) + 1 solid + 1 dashed per member × 2 = 6
109
+ expect(lines).toHaveLength(6);
110
+ const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
111
+ expect(dashed).toHaveLength(2); // 1 dashed per member
151
112
  });
152
113
 
153
- it('emits navigate when a partitive is clicked', async () => {
114
+ it('delimiting: drops use 3× stroke width', () => {
154
115
  const w = mount(PartitiveRelationDiagram, {
155
116
  props: {
156
117
  comprehensiveLabel: '0',
157
- partitives: members(2),
118
+ partitives: members(2, { isDelimiting: true }),
158
119
  completeness: 'complete',
159
- plurality: null,
160
120
  },
161
121
  });
162
- // Click any of the partitive <g> elements
163
- const groups = w.findAll('g.cursor-pointer');
164
- expect(groups.length).toBeGreaterThan(0);
165
- await groups[0].trigger('click');
166
- expect(w.emitted('navigate')).toBeTruthy();
122
+ // The drop lines should have stroke-width = 4.5 (vs frame's 1.4)
123
+ const lines = w.findAll('line');
124
+ const dropWidths = lines
125
+ .map(l => Number(l.attributes('stroke-width') || '0'))
126
+ .filter(w => w > 2); // delimiting width is 4.5
127
+ expect(dropWidths).toHaveLength(2); // 1 drop per member × 2 members
128
+ });
129
+ });
130
+
131
+ it('emits navigate when a partitive is clicked', async () => {
132
+ const w = mount(PartitiveRelationDiagram, {
133
+ props: {
134
+ comprehensiveLabel: '0',
135
+ partitives: members(2),
136
+ completeness: 'complete',
137
+ },
167
138
  });
139
+ const groups = w.findAll('g.cursor-pointer');
140
+ expect(groups.length).toBeGreaterThan(0);
141
+ await groups[0].trigger('click');
142
+ expect(w.emitted('navigate')).toBeTruthy();
168
143
  });
169
144
 
170
145
  describe('designation-aware sizing', () => {
@@ -174,75 +149,56 @@ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
174
149
  comprehensiveLabel: '1.3',
175
150
  partitives: members(2),
176
151
  completeness: 'complete',
177
- plurality: null,
178
152
  },
179
153
  });
180
154
  const svg = w.find('svg');
181
- // Short labels → MIN_NODE_W=110, total = 2*110 + 16 + 40 = 276
182
- const viewBox = svg.attributes('viewBox') || '';
183
- const width = Number(viewBox.split(' ')[2]);
155
+ const width = Number((svg.attributes('viewBox') || '').split(' ')[2]);
184
156
  expect(width).toBeLessThan(300);
185
157
  });
186
158
 
187
159
  it('grows nodes for long designations', () => {
188
- const longMembers: PartitiveMemberLabeled[] = [
189
- { uri: 'u1', label: 'system of quantities', certainty: 'confirmed' },
190
- { uri: 'u2', label: 'International System of Units', certainty: 'confirmed' },
191
- { uri: 'u3', label: 'base quantity dimension', certainty: 'confirmed' },
192
- ];
193
160
  const w = mount(PartitiveRelationDiagram, {
194
161
  props: {
195
162
  comprehensiveLabel: 'coordinate system of quantities',
196
- partitives: longMembers,
197
- completeness: 'complete',
198
- plurality: null,
199
- },
200
- });
201
- const svg = w.find('svg');
202
- const viewBox = svg.attributes('viewBox') || '';
203
- const width = Number(viewBox.split(' ')[2]);
204
- // Each node wider than MIN_NODE_W → total > 3*110 = 330
205
- expect(width).toBeGreaterThan(330);
206
- // Capped: node width ≤ MAX_NODE_W=220 → total ≤ 3*220 + 2*16 + 40 = 732
207
- expect(width).toBeLessThanOrEqual(732);
208
- });
209
-
210
- it('wraps long labels to 2 lines via tspan when needed', () => {
211
- const longLabel = 'International System of Quantities and Units';
212
- const w = mount(PartitiveRelationDiagram, {
213
- props: {
214
- comprehensiveLabel: longLabel,
215
163
  partitives: [
216
- { uri: 'u1', label: 'short', certainty: 'confirmed' },
217
- { uri: 'u2', label: 'short', certainty: 'confirmed' },
164
+ { uri: 'u1', label: 'system of quantities', multiplicity: 'compulsory', isDelimiting: false },
165
+ { uri: 'u2', label: 'International System of Units', multiplicity: 'compulsory', isDelimiting: false },
166
+ { uri: 'u3', label: 'base quantity dimension', multiplicity: 'compulsory', isDelimiting: false },
218
167
  ],
219
168
  completeness: 'complete',
220
- plurality: null,
221
169
  },
222
170
  });
223
- // The comprehensive text should have ≥ 2 tspans (wrapped)
224
- const texts = w.findAll('text');
225
- const compText = texts[0]; // first text is the comprehensive
226
- const tspans = compText.findAll('tspan');
227
- expect(tspans.length).toBeGreaterThanOrEqual(2);
171
+ const width = Number((w.find('svg').attributes('viewBox') || '').split(' ')[2]);
172
+ expect(width).toBeGreaterThan(330);
228
173
  });
174
+ });
229
175
 
230
- it('renders the designation (not the conceptId) as the label', () => {
231
- const w = mount(PartitiveRelationDiagram, {
232
- props: {
233
- comprehensiveLabel: 'system of quantities',
234
- partitives: [
235
- { uri: 'u1', label: 'base quantity', certainty: 'confirmed' },
236
- { uri: 'u2', label: 'derived quantity', certainty: 'confirmed' },
237
- ],
238
- completeness: 'complete',
239
- plurality: null,
240
- },
241
- });
242
- const allText = w.findAll('text').map(t => t.text());
243
- expect(allText).toContain('system of quantities');
244
- expect(allText).toContain('base quantity');
245
- expect(allText).toContain('derived quantity');
176
+ describe('consistency with rakeStrokeStyle helper', () => {
177
+ // Ensure the component's line rendering matches the pure helper
178
+ // (single source of truth — both must agree).
179
+ it('renders exactly the line count + dash pattern the helper predicts', () => {
180
+ const testCases: Array<{ multiplicity: PartitiveMultiplicity; isDelimiting: boolean }> = [
181
+ { multiplicity: 'compulsory', isDelimiting: false },
182
+ { multiplicity: 'compulsory', isDelimiting: true },
183
+ { multiplicity: 'optional', isDelimiting: false },
184
+ { multiplicity: 'compulsory_multiple', isDelimiting: false },
185
+ { multiplicity: 'optional_multiple', isDelimiting: false },
186
+ { multiplicity: 'compulsory_at_least_one', isDelimiting: false },
187
+ ];
188
+ for (const tc of testCases) {
189
+ const expected = rakeStrokeStyle(tc.multiplicity, tc.isDelimiting);
190
+ const w = mount(PartitiveRelationDiagram, {
191
+ props: {
192
+ comprehensiveLabel: '0',
193
+ partitives: members(1, tc),
194
+ completeness: 'complete',
195
+ },
196
+ });
197
+ const lines = w.findAll('line');
198
+ // stem (1) + spine (1) + drop lineCount per member
199
+ const expectedLines = 2 + expected.lineCount;
200
+ expect(lines.length).toBe(expectedLines);
201
+ }
246
202
  });
247
203
  });
248
204
  });
@@ -1,17 +1,20 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import type { PartitiveRelation, PartitiveMember, TypeSharedPlurality, ConceptRef } from 'glossarist';
2
+ // glossarist 0.4.24's top-level d.ts doesn't re-export the v3
3
+ // PartitiveRelation/PartitiveMember classes; import from glossarist/models
4
+ // (which our local augmentation extends) so the types are correct.
5
+ import type { PartitiveRelation, PartitiveMember, ConceptRef } from 'glossarist/models';
3
6
  import {
4
7
  PartitiveRelation as PartitiveRelationModel,
5
8
  PartitiveMember as PartitiveMemberModel,
6
- TypeSharedPlurality as TypeSharedPluralityModel,
7
9
  ConceptRef as ConceptRefModel,
8
10
  } from 'glossarist/models';
9
11
 
10
12
  /**
11
- * Verifies glossarist-js's native v2 PartitiveRelation model.
13
+ * Verifies glossarist-js 0.4.24's native v3 PartitiveRelation model
14
+ * (ISO 704:2022 multiplicity + is_delimiting).
12
15
  *
13
- * The concept-browser now consumes this directly (no `as any`
14
- * bridge). These specs lock in the contract so an upstream
16
+ * The concept-browser now consumes this directly (no migration adapter,
17
+ * no `as any` bridge). These specs lock in the contract so an upstream
15
18
  * regression is caught here.
16
19
  */
17
20
 
@@ -19,114 +22,139 @@ function makeRef(source: string, id: string): ConceptRef {
19
22
  return ConceptRefModel.fromJSON({ source, id }) as ConceptRef;
20
23
  }
21
24
 
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
+ function makePartitive(
26
+ source: string,
27
+ id: string,
28
+ multiplicity?: 'compulsory' | 'optional' | 'compulsory_multiple' | 'optional_multiple' | 'compulsory_at_least_one',
29
+ isDelimiting?: boolean,
30
+ ): PartitiveMember {
31
+ const data: {
32
+ ref: ConceptRef;
33
+ multiplicity?: typeof multiplicity;
34
+ is_delimiting?: boolean;
35
+ } = { ref: makeRef(source, id) };
36
+ if (multiplicity) data.multiplicity = multiplicity;
37
+ if (isDelimiting) data.is_delimiting = isDelimiting;
25
38
  return PartitiveMemberModel.fromJSON(data as unknown as Record<string, unknown>) as PartitiveMember;
26
39
  }
27
40
 
28
- function makePlurality(isShared: boolean, isUncertain = false): TypeSharedPlurality {
29
- return TypeSharedPluralityModel.fromJSON({ isShared, isUncertain }) as TypeSharedPlurality;
30
- }
31
-
32
41
  function makeRelation(opts: {
33
42
  comprehensive: ConceptRef;
34
43
  partitives: PartitiveMember[];
35
44
  completeness?: 'complete' | 'partial';
36
- plurality?: TypeSharedPlurality | null;
37
45
  criterion?: Record<string, string>;
38
46
  }): PartitiveRelation {
39
47
  return PartitiveRelationModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveRelation;
40
48
  }
41
49
 
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
- });
50
+ describe('useConceptEdges — native v3 PartitiveRelation (glossarist 0.4.24)', () => {
51
+ describe('PartitiveMember (ISO 704:2022 multiplicity + delimiting)', () => {
52
+ it('multiplicity defaults to compulsory when omitted', () => {
53
+ const m = makePartitive('test', '1.1');
54
+ expect(m.multiplicity).toBe('compulsory');
55
+ expect(m.isCompulsory).toBe(true);
56
+ });
47
57
 
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
- });
58
+ it('multiplicity is preserved when set to optional', () => {
59
+ const m = makePartitive('test', '1.1', 'optional');
60
+ expect(m.multiplicity).toBe('optional');
61
+ expect(m.isOptional).toBe(true);
62
+ });
53
63
 
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
- });
64
+ it('is_delimiting defaults to false', () => {
65
+ const m = makePartitive('test', '1.1');
66
+ expect(m.is_delimiting).toBe(false);
67
+ expect(m.isDelimiting).toBe(false);
68
+ });
59
69
 
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')],
70
+ it('is_delimiting is preserved when set to true', () => {
71
+ const m = makePartitive('test', '1.1', 'compulsory', true);
72
+ expect(m.is_delimiting).toBe(true);
64
73
  });
65
- expect(r.completeness).toBe('complete');
66
- expect(r.isComplete).toBe(true);
67
- });
68
74
 
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',
75
+ it('multiplicity + is_delimiting are orthogonal axes', () => {
76
+ const m = makePartitive('test', '1.1', 'compulsory_multiple', true);
77
+ expect(m.multiplicity).toBe('compulsory_multiple');
78
+ expect(m.is_delimiting).toBe(true);
74
79
  });
75
- expect(r.completeness).toBe('partial');
76
- expect(r.isPartial).toBe(true);
77
80
  });
78
81
 
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),
82
+ describe('PartitiveRelation', () => {
83
+ it('accepts 2 compulsory members with completeness=complete', () => {
84
+ const r = makeRelation({
85
+ comprehensive: makeRef('test', '1.3'),
86
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
87
+ });
88
+ expect(r.partitives).toHaveLength(2);
89
+ expect(r.completeness).toBe('complete');
90
+ expect(r.isComplete).toBe(true);
91
+ expect(r.isPartial).toBe(false);
84
92
  });
85
- expect(r.hasPlurality()).toBe(true);
86
- expect(r.plurality?.isShared).toBe(true);
87
- expect(r.plurality?.isUncertain).toBe(true);
88
- });
89
93
 
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' },
94
+ it('completeness=partial is preserved', () => {
95
+ const r = makeRelation({
96
+ comprehensive: makeRef('test', '1.3'),
97
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
98
+ completeness: 'partial',
99
+ });
100
+ expect(r.completeness).toBe('partial');
101
+ expect(r.isPartial).toBe(true);
95
102
  });
96
- expect(r.hasCriterion()).toBe(true);
97
- expect(r.criterion?.eng).toBe('physical structure');
98
- });
99
103
 
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
- });
104
+ it('member with compulsory_multiple has isCompulsory false', () => {
105
+ const m = makePartitive('test', '1.4', 'compulsory_multiple');
106
+ expect(m.isCompulsory).toBe(false);
107
+ expect(m.multiplicity).not.toBe('compulsory');
108
+ });
106
109
 
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
- });
110
+ it('all compulsory-single members have isCompulsory true', () => {
111
+ const r = makeRelation({
112
+ comprehensive: makeRef('test', '1.3'),
113
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
114
+ });
115
+ expect(r.partitives[0].isCompulsory).toBe(true);
116
+ expect(r.partitives[1].isCompulsory).toBe(true);
117
+ });
118
+
119
+ it('isCoordinate true when ≥2 partitives', () => {
120
+ const r = makeRelation({
121
+ comprehensive: makeRef('test', '1.3'),
122
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
123
+ });
124
+ expect(r.isCoordinate).toBe(true);
125
+ });
126
+
127
+ it('rejects < 2 partitives (ISO 704 requirement)', () => {
128
+ expect(() => makeRelation({
129
+ comprehensive: makeRef('test', '1.3'),
130
+ partitives: [makePartitive('test', '1.4')],
131
+ })).toThrow(/≥2 partitives/);
132
+ });
116
133
 
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' },
134
+ it('round-trips through toJSON with v3 fields', () => {
135
+ const r = makeRelation({
136
+ comprehensive: makeRef('test', '1.3'),
137
+ partitives: [
138
+ makePartitive('test', '1.4', 'compulsory', false),
139
+ makePartitive('test', '1.5', 'compulsory_multiple', true),
140
+ ],
141
+ completeness: 'partial',
142
+ criterion: { eng: 'physical structure' },
143
+ });
144
+ const json = r.toJSON() as {
145
+ comprehensive: unknown;
146
+ partitives: Array<{ multiplicity?: string; is_delimiting?: boolean; ref: unknown }>;
147
+ completeness: string;
148
+ criterion?: Record<string, string>;
149
+ };
150
+ expect(json.comprehensive).toBeDefined();
151
+ expect(json.partitives).toHaveLength(2);
152
+ // compulsory is the default multiplicity — not emitted in toJSON
153
+ expect(json.partitives[0].multiplicity).toBeUndefined();
154
+ expect(json.partitives[1].multiplicity).toBe('compulsory_multiple');
155
+ expect(json.partitives[1].is_delimiting).toBe(true);
156
+ expect(json.completeness).toBe('partial');
157
+ expect(json.criterion).toEqual({ eng: 'physical structure' });
124
158
  });
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
159
  });
132
160
  });