@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.
@@ -3,26 +3,31 @@ import { mount } from '@vue/test-utils';
3
3
  import PartitiveRelationDiagram, {
4
4
  type PartitiveMemberLabeled,
5
5
  } from '../../components/PartitiveRelationDiagram.vue';
6
+ import {
7
+ rakeStrokeStyle,
8
+ type PartitivePresence,
9
+ type PartitiveCount,
10
+ } from '../../utils/partitive-multiplicity';
6
11
 
7
- const members = (n: number, certainty: 'confirmed' | 'possible' = 'confirmed'): PartitiveMemberLabeled[] =>
12
+ const members = (
13
+ n: number,
14
+ opts: { presence?: PartitivePresence; count?: PartitiveCount; isDelimiting?: boolean } = {},
15
+ ): PartitiveMemberLabeled[] =>
8
16
  Array.from({ length: n }, (_, i) => ({
9
17
  uri: `https://example.org/test/concept/1.${i + 1}`,
10
18
  label: `1.${i + 1}`,
11
- certainty,
19
+ presence: opts.presence ?? 'required',
20
+ count: opts.count ?? 'exactly_one',
21
+ isDelimiting: opts.isDelimiting ?? false,
12
22
  }));
13
23
 
14
- function svgLinesOf(wrapper: ReturnType<typeof mount>) {
15
- return wrapper.findAll('line');
16
- }
17
-
18
- describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
24
+ describe('PartitiveRelationDiagram — ISO 704:2022 rake rendering (MECE)', () => {
19
25
  it('renders comprehensive + N partitive nodes as text', () => {
20
26
  const w = mount(PartitiveRelationDiagram, {
21
27
  props: {
22
28
  comprehensiveLabel: '1.3',
23
29
  partitives: members(3),
24
30
  completeness: 'complete',
25
- plurality: null,
26
31
  },
27
32
  });
28
33
  const texts = w.findAll('text').map(t => t.text());
@@ -38,135 +43,110 @@ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
38
43
  comprehensiveLabel: '1.3',
39
44
  partitives: members(2),
40
45
  completeness: 'complete',
41
- plurality: null,
42
46
  criterion: 'physical structure',
43
47
  },
44
48
  });
45
49
  expect(w.findAll('text').map(t => t.text())).toContain('physical structure');
46
50
  });
47
51
 
48
- describe('line variant by plurality', () => {
49
- it('single solid line when no plurality (3 lines: stem + spine + 2 drops = 4 actually)', () => {
52
+ describe('per-member line rendering (presence × count × isDelimiting)', () => {
53
+ it('required/exactly_one: stem + spine + 1 solid line per drop', () => {
50
54
  const w = mount(PartitiveRelationDiagram, {
51
55
  props: {
52
- comprehensiveLabel: '1.3',
56
+ comprehensiveLabel: '0',
53
57
  partitives: members(2),
54
58
  completeness: 'complete',
55
- plurality: null,
56
59
  },
57
60
  });
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);
61
+ // stem (1) + spine (1) + 1 drop line per member × 2 members = 4
62
+ expect(w.findAll('line')).toHaveLength(4);
63
63
  });
64
64
 
65
- it('close-set double solid lines when isShared && !isUncertain', () => {
65
+ it('required/multiple: each drop has 2 solid lines', () => {
66
66
  const w = mount(PartitiveRelationDiagram, {
67
67
  props: {
68
- comprehensiveLabel: '1.3',
69
- partitives: members(2),
68
+ comprehensiveLabel: '0',
69
+ partitives: members(2, { count: 'multiple' }),
70
70
  completeness: 'complete',
71
- plurality: { isShared: true, isUncertain: false },
72
71
  },
73
72
  });
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);
73
+ // stem + spine + 2 drop lines per member × 2 = 6
74
+ expect(w.findAll('line')).toHaveLength(6);
78
75
  });
79
76
 
80
- it('one solid + one dashed when isShared && isUncertain', () => {
77
+ it('optional/exactly_one: drop is dashed', () => {
81
78
  const w = mount(PartitiveRelationDiagram, {
82
79
  props: {
83
- comprehensiveLabel: '1.3',
84
- partitives: members(2),
80
+ comprehensiveLabel: '0',
81
+ partitives: members(2, { presence: 'optional' }),
85
82
  completeness: 'complete',
86
- plurality: { isShared: true, isUncertain: true },
87
83
  },
88
84
  });
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);
85
+ // stem + spine (frame, not dashed) + 2 drops (dashed)
86
+ const lines = w.findAll('line');
92
87
  const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
93
- // exactly 2 dashed lines (the second stem + the second spine)
94
88
  expect(dashed).toHaveLength(2);
95
89
  });
96
- });
97
90
 
98
- describe('completeness: partial extends spine', () => {
99
- it('extends spine past last tooth when completeness=partial', () => {
91
+ it('optional/multiple: each drop has 2 dashed lines', () => {
100
92
  const w = mount(PartitiveRelationDiagram, {
101
93
  props: {
102
- comprehensiveLabel: '1.3',
103
- partitives: members(2),
104
- completeness: 'partial',
105
- plurality: null,
94
+ comprehensiveLabel: '0',
95
+ partitives: members(2, { presence: 'optional', count: 'multiple' }),
96
+ completeness: 'complete',
106
97
  },
107
98
  });
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;
99
+ const lines = w.findAll('line');
100
+ const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
101
+ expect(dashed).toHaveLength(4); // 2 members × 2 dashed lines each
127
102
  });
128
- });
129
103
 
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
- ];
104
+ it('required/at_least_one: each drop has 1 solid + 1 dashed line', () => {
136
105
  const w = mount(PartitiveRelationDiagram, {
137
106
  props: {
138
107
  comprehensiveLabel: '0',
139
- partitives: possibleMembers,
108
+ partitives: members(2, { count: 'at_least_one' }),
140
109
  completeness: 'complete',
141
- plurality: null,
142
110
  },
143
111
  });
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();
112
+ const lines = w.findAll('line');
113
+ // stem + spine (solid) + 1 solid + 1 dashed per member × 2 = 6
114
+ expect(lines).toHaveLength(6);
115
+ const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
116
+ expect(dashed).toHaveLength(2); // 1 dashed per member
151
117
  });
152
118
 
153
- it('emits navigate when a partitive is clicked', async () => {
119
+ it('delimiting: drops use 3× stroke width', () => {
154
120
  const w = mount(PartitiveRelationDiagram, {
155
121
  props: {
156
122
  comprehensiveLabel: '0',
157
- partitives: members(2),
123
+ partitives: members(2, { isDelimiting: true }),
158
124
  completeness: 'complete',
159
- plurality: null,
160
125
  },
161
126
  });
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();
127
+ // The drop lines should have stroke-width = 4.5 (vs frame's 1.4)
128
+ const lines = w.findAll('line');
129
+ const dropWidths = lines
130
+ .map(l => Number(l.attributes('stroke-width') || '0'))
131
+ .filter(w => w > 2); // delimiting width is 4.5
132
+ expect(dropWidths).toHaveLength(2); // 1 drop per member × 2 members
167
133
  });
168
134
  });
169
135
 
136
+ it('emits navigate when a partitive is clicked', async () => {
137
+ const w = mount(PartitiveRelationDiagram, {
138
+ props: {
139
+ comprehensiveLabel: '0',
140
+ partitives: members(2),
141
+ completeness: 'complete',
142
+ },
143
+ });
144
+ const groups = w.findAll('g.cursor-pointer');
145
+ expect(groups.length).toBeGreaterThan(0);
146
+ await groups[0].trigger('click');
147
+ expect(w.emitted('navigate')).toBeTruthy();
148
+ });
149
+
170
150
  describe('designation-aware sizing', () => {
171
151
  it('uses MIN_NODE_W when labels are short', () => {
172
152
  const w = mount(PartitiveRelationDiagram, {
@@ -174,75 +154,57 @@ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
174
154
  comprehensiveLabel: '1.3',
175
155
  partitives: members(2),
176
156
  completeness: 'complete',
177
- plurality: null,
178
157
  },
179
158
  });
180
159
  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]);
160
+ const width = Number((svg.attributes('viewBox') || '').split(' ')[2]);
184
161
  expect(width).toBeLessThan(300);
185
162
  });
186
163
 
187
164
  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
165
  const w = mount(PartitiveRelationDiagram, {
194
166
  props: {
195
167
  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
168
  partitives: [
216
- { uri: 'u1', label: 'short', certainty: 'confirmed' },
217
- { uri: 'u2', label: 'short', certainty: 'confirmed' },
169
+ { uri: 'u1', label: 'system of quantities', presence: 'required', count: 'exactly_one', isDelimiting: false },
170
+ { uri: 'u2', label: 'International System of Units', presence: 'required', count: 'exactly_one', isDelimiting: false },
171
+ { uri: 'u3', label: 'base quantity dimension', presence: 'required', count: 'exactly_one', isDelimiting: false },
218
172
  ],
219
173
  completeness: 'complete',
220
- plurality: null,
221
174
  },
222
175
  });
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);
176
+ const width = Number((w.find('svg').attributes('viewBox') || '').split(' ')[2]);
177
+ expect(width).toBeGreaterThan(330);
228
178
  });
179
+ });
229
180
 
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
- },
181
+ describe('consistency with rakeStrokeStyle helper (MECE truth table)', () => {
182
+ // Ensure the component's line rendering matches the pure helper
183
+ // (single source of truth — both must agree).
184
+ const testCases: Array<{ presence: PartitivePresence; count: PartitiveCount; isDelimiting: boolean }> = [
185
+ { presence: 'required', count: 'exactly_one', isDelimiting: false },
186
+ { presence: 'required', count: 'exactly_one', isDelimiting: true },
187
+ { presence: 'optional', count: 'exactly_one', isDelimiting: false },
188
+ { presence: 'required', count: 'multiple', isDelimiting: false },
189
+ { presence: 'optional', count: 'multiple', isDelimiting: false },
190
+ { presence: 'required', count: 'at_least_one', isDelimiting: false },
191
+ { presence: 'optional', count: 'at_least_one', isDelimiting: false },
192
+ ];
193
+ for (const tc of testCases) {
194
+ it(`${tc.presence} × ${tc.count}${tc.isDelimiting ? ' / delimiting' : ''}`, () => {
195
+ const expected = rakeStrokeStyle(tc.presence, tc.count, tc.isDelimiting);
196
+ const w = mount(PartitiveRelationDiagram, {
197
+ props: {
198
+ comprehensiveLabel: '0',
199
+ partitives: members(1, tc),
200
+ completeness: 'complete',
201
+ },
202
+ });
203
+ const lines = w.findAll('line');
204
+ // stem (1) + spine (1) + drop lineCount per member
205
+ const expectedLines = 2 + expected.lineCount;
206
+ expect(lines.length).toBe(expectedLines);
241
207
  });
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');
246
- });
208
+ }
247
209
  });
248
210
  });
@@ -1,132 +1,167 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import type { PartitiveRelation, PartitiveMember, TypeSharedPlurality, ConceptRef } from 'glossarist';
2
+ // glossarist-js 0.4.26's top-level d.ts doesn't re-export the MECE
3
+ // PartitiveRelation/PartitiveMember classes; import from glossarist/models
4
+ // (extended by our local augmentation) so the types are correct.
5
+ import type {
6
+ PartitiveRelation,
7
+ PartitiveMember,
8
+ ConceptRef,
9
+ } from 'glossarist/models';
3
10
  import {
4
11
  PartitiveRelation as PartitiveRelationModel,
5
12
  PartitiveMember as PartitiveMemberModel,
6
- TypeSharedPlurality as TypeSharedPluralityModel,
7
13
  ConceptRef as ConceptRefModel,
8
14
  } from 'glossarist/models';
9
15
 
10
16
  /**
11
- * Verifies glossarist-js's native v2 PartitiveRelation model.
17
+ * Verifies glossarist-js 0.4.26's MECE-native PartitiveRelation model
18
+ * (ISO 704:2022 presence × count + is_delimiting).
12
19
  *
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.
20
+ * The concept-browser consumes these fields directly (no migration
21
+ * bridge). These specs pin the runtime contract so a regression in
22
+ * upstream is caught here.
16
23
  */
17
24
 
18
25
  function makeRef(source: string, id: string): ConceptRef {
19
26
  return ConceptRefModel.fromJSON({ source, id }) as ConceptRef;
20
27
  }
21
28
 
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;
29
+ function makePartitive(
30
+ source: string,
31
+ id: string,
32
+ fields: { presence?: 'required' | 'optional'; count?: 'exactly_one' | 'at_least_one' | 'multiple'; isDelimiting?: boolean } = {},
33
+ ): PartitiveMember {
34
+ const data: {
35
+ ref: ConceptRef;
36
+ presence?: 'required' | 'optional';
37
+ count?: 'exactly_one' | 'at_least_one' | 'multiple';
38
+ is_delimiting?: boolean;
39
+ } = { ref: makeRef(source, id) };
40
+ if (fields.presence) data.presence = fields.presence;
41
+ if (fields.count) data.count = fields.count;
42
+ if (fields.isDelimiting) data.is_delimiting = fields.isDelimiting;
25
43
  return PartitiveMemberModel.fromJSON(data as unknown as Record<string, unknown>) as PartitiveMember;
26
44
  }
27
45
 
28
- function makePlurality(isShared: boolean, isUncertain = false): TypeSharedPlurality {
29
- return TypeSharedPluralityModel.fromJSON({ isShared, isUncertain }) as TypeSharedPlurality;
30
- }
31
-
32
46
  function makeRelation(opts: {
33
47
  comprehensive: ConceptRef;
34
48
  partitives: PartitiveMember[];
35
49
  completeness?: 'complete' | 'partial';
36
- plurality?: TypeSharedPlurality | null;
37
50
  criterion?: Record<string, string>;
38
51
  }): PartitiveRelation {
39
52
  return PartitiveRelationModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveRelation;
40
53
  }
41
54
 
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
- });
55
+ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
56
+ describe('PartitiveMember (ISO 704:2022 presence × count + delimiting)', () => {
57
+ it('presence defaults to required, count defaults to exactly_one', () => {
58
+ const m = makePartitive('test', '1.1');
59
+ expect(m.presence).toBe('required');
60
+ expect(m.isRequired).toBe(true);
61
+ expect(m.isOptional).toBe(false);
62
+ });
47
63
 
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
- });
64
+ it('count defaults to exactly_one', () => {
65
+ const m = makePartitive('test', '1.1');
66
+ expect(m.count).toBe('exactly_one');
67
+ });
53
68
 
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
- });
69
+ it('presence=optional is preserved', () => {
70
+ const m = makePartitive('test', '1.1', { presence: 'optional' });
71
+ expect(m.presence).toBe('optional');
72
+ expect(m.isOptional).toBe(true);
73
+ expect(m.isRequired).toBe(false);
74
+ });
59
75
 
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')],
76
+ it('count=multiple is preserved', () => {
77
+ const m = makePartitive('test', '1.1', { count: 'multiple' });
78
+ expect(m.count).toBe('multiple');
64
79
  });
65
- expect(r.completeness).toBe('complete');
66
- expect(r.isComplete).toBe(true);
67
- });
68
80
 
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',
81
+ it('count=at_least_one is preserved', () => {
82
+ const m = makePartitive('test', '1.1', { count: 'at_least_one' });
83
+ expect(m.count).toBe('at_least_one');
74
84
  });
75
- expect(r.completeness).toBe('partial');
76
- expect(r.isPartial).toBe(true);
77
- });
78
85
 
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),
86
+ it('is_delimiting defaults to false', () => {
87
+ const m = makePartitive('test', '1.1');
88
+ expect(m.is_delimiting).toBe(false);
89
+ expect(m.isDelimiting).toBe(false);
84
90
  });
85
- expect(r.hasPlurality()).toBe(true);
86
- expect(r.plurality?.isShared).toBe(true);
87
- expect(r.plurality?.isUncertain).toBe(true);
88
- });
89
91
 
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' },
92
+ it('is_delimiting is preserved when set to true', () => {
93
+ const m = makePartitive('test', '1.1', { isDelimiting: true });
94
+ expect(m.is_delimiting).toBe(true);
95
+ expect(m.isDelimiting).toBe(true);
95
96
  });
96
- expect(r.hasCriterion()).toBe(true);
97
- expect(r.criterion?.eng).toBe('physical structure');
98
- });
99
97
 
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/);
98
+ it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
99
+ expect(() =>
100
+ makePartitive('test', '1.1', { presence: 'optional', count: 'at_least_one' }),
101
+ ).toThrow(/collapses to optional \+ multiple/);
102
+ });
105
103
  });
106
104
 
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
- });
105
+ describe('PartitiveRelation', () => {
106
+ it('accepts 2 members with default axes', () => {
107
+ const r = makeRelation({
108
+ comprehensive: makeRef('test', '1.3'),
109
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
110
+ });
111
+ expect(r.partitives).toHaveLength(2);
112
+ expect(r.completeness).toBe('complete');
113
+ expect(r.isComplete).toBe(true);
114
+ expect(r.isPartial).toBe(false);
115
+ });
116
+
117
+ it('completeness=partial is preserved', () => {
118
+ const r = makeRelation({
119
+ comprehensive: makeRef('test', '1.3'),
120
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
121
+ completeness: 'partial',
122
+ });
123
+ expect(r.completeness).toBe('partial');
124
+ expect(r.isPartial).toBe(true);
125
+ });
126
+
127
+ it('isCoordinate true when ≥2 partitives', () => {
128
+ const r = makeRelation({
129
+ comprehensive: makeRef('test', '1.3'),
130
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
131
+ });
132
+ expect(r.isCoordinate).toBe(true);
133
+ });
134
+
135
+ it('rejects < 2 partitives (ISO 704 requirement)', () => {
136
+ expect(() => makeRelation({
137
+ comprehensive: makeRef('test', '1.3'),
138
+ partitives: [makePartitive('test', '1.4')],
139
+ })).toThrow(/≥2 partitives/);
140
+ });
116
141
 
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' },
142
+ it('round-trips through toJSON with MECE fields', () => {
143
+ const r = makeRelation({
144
+ comprehensive: makeRef('test', '1.3'),
145
+ partitives: [
146
+ makePartitive('test', '1.4', { presence: 'required', count: 'multiple' }),
147
+ makePartitive('test', '1.5', { presence: 'optional', isDelimiting: true }),
148
+ ],
149
+ completeness: 'partial',
150
+ criterion: { eng: 'physical structure' },
151
+ });
152
+ const json = r.toJSON() as {
153
+ comprehensive: unknown;
154
+ partitives: Array<{ presence?: string; count?: string; is_delimiting?: boolean; ref: unknown }>;
155
+ completeness: string;
156
+ criterion?: Record<string, string>;
157
+ };
158
+ expect(json.comprehensive).toBeDefined();
159
+ expect(json.partitives).toHaveLength(2);
160
+ expect(json.partitives[0].count).toBe('multiple');
161
+ expect(json.partitives[1].presence).toBe('optional');
162
+ expect(json.partitives[1].is_delimiting).toBe(true);
163
+ expect(json.completeness).toBe('partial');
164
+ expect(json.criterion).toEqual({ eng: 'physical structure' });
124
165
  });
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
166
  });
132
167
  });