@glossarist/concept-browser 0.7.92 → 0.7.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.92",
3
+ "version": "0.7.93",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "astro": "^7.0.7",
38
38
  "d3": "^7.9.0",
39
39
  "favicons": "^7.2.0",
40
- "glossarist": "^0.4.33",
40
+ "glossarist": "^0.4.34",
41
41
  "js-yaml": "^4.1.0",
42
42
  "jszip": "^3.10.1",
43
43
  "pinia": "^2.3.1",
@@ -58,8 +58,8 @@ function makeRelation(overrides: Partial<GenericRelationWire> = {}): GenericRela
58
58
  source: 'https://example.org/test/concept/5-1',
59
59
  comprehensive: 'https://example.org/test/concept/5-1',
60
60
  members: [
61
- { uri: 'https://example.org/test/concept/5-4', presence: 'required', count: 'exactly_one', isDelimiting: false },
62
- { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
61
+ { uri: 'https://example.org/test/concept/5-4', presence: 'required', count: 'exactly_one', delimitingCharacteristic: { eng: "test characteristic" } },
62
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', delimitingCharacteristic: { eng: "test characteristic" } },
63
63
  ],
64
64
  completeness: 'complete',
65
65
  register: 'test',
@@ -120,8 +120,8 @@ describe('GenericRelationList — wire-type contract mount test', () => {
120
120
  props: {
121
121
  relations: [makeRelation({
122
122
  members: [
123
- { uri: 'https://example.org/test/concept/5-4', presence: 'optional', count: 'multiple', isDelimiting: true },
124
- { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
123
+ { uri: 'https://example.org/test/concept/5-4', presence: 'optional', count: 'multiple', delimitingCharacteristic: { eng: "delimiting by light sensors" } },
124
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', delimitingCharacteristic: { eng: "test characteristic" } },
125
125
  ],
126
126
  })],
127
127
  manifest,
@@ -0,0 +1,147 @@
1
+ /**
2
+ * GenericRelationList mount test — pins the wire-type contract so
3
+ * the 5 type bugs found in audit (entry.titles, member.ref,
4
+ * is_delimiting casing, comprehensive.source, missing props)
5
+ * can't regress.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { mount } from '@vue/test-utils';
9
+ import GenericRelationList from '../../components/GenericRelationList.vue';
10
+ import type { GenericRelationWire } from '../../adapters/types';
11
+
12
+ // Stub the composables the component depends on
13
+ vi.mock('../../stores/vocabulary', () => ({
14
+ useVocabularyStore: () => ({
15
+ graph: { getNode: () => null },
16
+ datasets: new Map(),
17
+ }),
18
+ }));
19
+
20
+ vi.mock('../../adapters/factory', () => ({
21
+ getFactory: () => ({
22
+ resolve: (uri: string) => ({ type: 'unresolved' as const, uri }),
23
+ }),
24
+ }));
25
+
26
+ vi.mock('../../i18n', () => ({
27
+ useI18n: () => ({ t: (key: string) => key }),
28
+ locale: { value: 'eng' },
29
+ }));
30
+
31
+ vi.mock('vue-router', () => ({
32
+ useRouter: () => ({ push: vi.fn() }),
33
+ }));
34
+
35
+ const manifest = {
36
+ id: 'test',
37
+ datasetUri: 'https://example.org/test',
38
+ title: 'Test',
39
+ description: '',
40
+ owner: '',
41
+ baseUrl: '',
42
+ languages: ['eng'],
43
+ conceptCount: 0,
44
+ conceptUrlTemplate: '',
45
+ indexUrl: '',
46
+ contextUrl: '',
47
+ uriBase: 'https://example.org',
48
+ status: 'valid',
49
+ schemaVersion: '3',
50
+ tags: [],
51
+ lastUpdated: '',
52
+ sourceRepo: '',
53
+ chunkSize: 100,
54
+ } as any;
55
+
56
+ function makeRelation(overrides: Partial<GenericRelationWire> = {}): GenericRelationWire {
57
+ return {
58
+ source: 'https://example.org/test/concept/5-1',
59
+ comprehensive: 'https://example.org/test/concept/5-1',
60
+ members: [
61
+ { uri: 'https://example.org/test/concept/5-4', presence: 'required', count: 'exactly_one', isDelimiting: false },
62
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
63
+ ],
64
+ completeness: 'complete',
65
+ register: 'test',
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ describe('GenericRelationList — wire-type contract mount test', () => {
71
+ it('mounts without errors for a well-formed relation', () => {
72
+ const w = mount(GenericRelationList, {
73
+ props: {
74
+ relations: [makeRelation()],
75
+ manifest,
76
+ registerId: 'test',
77
+ },
78
+ });
79
+ expect(w.html()).toContain('generic-relation-card');
80
+ });
81
+
82
+ it('renders completeness badge', () => {
83
+ const w = mount(GenericRelationList, {
84
+ props: {
85
+ relations: [makeRelation({ completeness: 'partial' })],
86
+ manifest,
87
+ registerId: 'test',
88
+ },
89
+ });
90
+ expect(w.html()).toContain('completeness-partial');
91
+ });
92
+
93
+ it('renders multiple relations', () => {
94
+ const w = mount(GenericRelationList, {
95
+ props: {
96
+ relations: [
97
+ makeRelation({ comprehensive: 'https://example.org/test/concept/5-1' }),
98
+ makeRelation({ comprehensive: 'https://example.org/test/concept/5-2' }),
99
+ ],
100
+ manifest,
101
+ registerId: 'test',
102
+ },
103
+ });
104
+ expect(w.findAll('.generic-relation-card')).toHaveLength(2);
105
+ });
106
+
107
+ it('renders criterion when present', () => {
108
+ const w = mount(GenericRelationList, {
109
+ props: {
110
+ relations: [makeRelation({ criterion: { eng: 'by realization medium' } })],
111
+ manifest,
112
+ registerId: 'test',
113
+ },
114
+ });
115
+ expect(w.html()).toContain('by realization medium');
116
+ });
117
+
118
+ it('renders member presence/count badges for non-default values', () => {
119
+ const w = mount(GenericRelationList, {
120
+ props: {
121
+ relations: [makeRelation({
122
+ members: [
123
+ { uri: 'https://example.org/test/concept/5-4', presence: 'optional', count: 'multiple', isDelimiting: true },
124
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
125
+ ],
126
+ })],
127
+ manifest,
128
+ registerId: 'test',
129
+ },
130
+ });
131
+ const html = w.html();
132
+ expect(html).toContain('Optional');
133
+ expect(html).toContain('Multiple');
134
+ });
135
+
136
+ it('renders empty section when relations array is empty', () => {
137
+ const w = mount(GenericRelationList, {
138
+ props: {
139
+ relations: [],
140
+ manifest,
141
+ registerId: 'test',
142
+ },
143
+ });
144
+ expect(w.find('section.generic-relations').exists()).toBe(true);
145
+ expect(w.findAll('.generic-relation-card')).toHaveLength(0);
146
+ });
147
+ });
@@ -39,7 +39,7 @@ function makePartitive(
39
39
  if (fields.presence) data.presence = fields.presence;
40
40
  if (fields.count) data.count = fields.count;
41
41
  if (fields.isDelimiting) data.is_delimiting = fields.isDelimiting;
42
- return PartitiveMemberModel.fromJSON(data as unknown as Record<string, unknown>) as PartitiveMember;
42
+ return new PartitiveMemberModel(data as any) as PartitiveMember;
43
43
  }
44
44
 
45
45
  function makeRelation(opts: {
@@ -48,7 +48,7 @@ function makeRelation(opts: {
48
48
  completeness?: 'complete' | 'partial';
49
49
  criterion?: Record<string, string>;
50
50
  }): PartitiveHyperedge {
51
- return PartitiveHyperedgeModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveHyperedge;
51
+ return new PartitiveHyperedgeModel(opts as any) as PartitiveHyperedge;
52
52
  }
53
53
 
54
54
  describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
@@ -84,14 +84,14 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
84
84
 
85
85
  it('is_delimiting defaults to false', () => {
86
86
  const m = makePartitive('test', '1.1');
87
- expect(m.is_delimiting).toBe(false);
88
- expect(m.is_delimiting).toBe(false);
87
+ expect((m as any).is_delimiting).toBe(false);
88
+ expect((m as any).is_delimiting).toBe(false);
89
89
  });
90
90
 
91
91
  it('is_delimiting is preserved when set to true', () => {
92
92
  const m = makePartitive('test', '1.1', { isDelimiting: true });
93
- expect(m.is_delimiting).toBe(true);
94
- expect(m.is_delimiting).toBe(true);
93
+ expect((m as any).is_delimiting).toBe(true);
94
+ expect((m as any).is_delimiting).toBe(true);
95
95
  });
96
96
 
97
97
  it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
@@ -84,14 +84,14 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
84
84
 
85
85
  it('is_delimiting defaults to false', () => {
86
86
  const m = makePartitive('test', '1.1');
87
- expect(m.is_delimiting).toBe(false);
88
- expect(m.is_delimiting).toBe(false);
87
+ expect((m as any).is_delimiting).toBe(false);
88
+ expect((m as any).is_delimiting).toBe(false);
89
89
  });
90
90
 
91
91
  it('is_delimiting is preserved when set to true', () => {
92
92
  const m = makePartitive('test', '1.1', { isDelimiting: true });
93
- expect(m.is_delimiting).toBe(true);
94
- expect(m.is_delimiting).toBe(true);
93
+ expect((m as any).is_delimiting).toBe(true);
94
+ expect((m as any).is_delimiting).toBe(true);
95
95
  });
96
96
 
97
97
  it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
@@ -107,7 +107,7 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
107
107
  comprehensive: makeRef('test', '1.3'),
108
108
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
109
109
  });
110
- expect(r.members).toHaveLength(2);
110
+ expect((r as any).members).toHaveLength(2);
111
111
  expect((r as any).completeness).toBe('complete');
112
112
  expect((r as any).isComplete).toBe(true);
113
113
  expect((r as any).isPartial).toBe(false);
@@ -60,6 +60,6 @@ describe('GL wire-key SSOT', () => {
60
60
  // The point of this test: when adding a key, you remember to update
61
61
  // the count; when removing, you notice the test fails. Catches
62
62
  // accidental drift in either direction.
63
- expect(Object.keys(GL).length).toBe(90);
63
+ expect(Object.keys(GL).length).toBe(91);
64
64
  });
65
65
  });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Wire-key SSOT contract.
3
+ *
4
+ * Audit-driven (AUDIT-2026-07-29 P1-3).
5
+ *
6
+ * The GL record is the single source of truth for the JSON-LD wire-format
7
+ * keys used across src/adapters/. This spec pins:
8
+ *
9
+ * - Every entry is a string starting with 'gl:' (or '@' for JSON-LD core)
10
+ * - No two entries share the same value (injectivity — one key, one name)
11
+ * - The set of values matches what's declared in jsonld-types.ts
12
+ * (catches drift if someone adds a key to one but not the other)
13
+ */
14
+ import { describe, it, expect } from 'vitest';
15
+ import { GL } from '../adapters/wire-keys';
16
+
17
+ describe('GL wire-key SSOT', () => {
18
+ it('every value is a non-empty string', () => {
19
+ for (const value of Object.values(GL)) {
20
+ expect(typeof value).toBe('string');
21
+ expect(value.length).toBeGreaterThan(0);
22
+ }
23
+ });
24
+
25
+ it('every value starts with gl: or @ (JSON-LD namespace)', () => {
26
+ for (const value of Object.values(GL)) {
27
+ expect(value.startsWith('gl:') || value.startsWith('@')).toBe(true);
28
+ }
29
+ });
30
+
31
+ it('no two entries share the same value (injectivity)', () => {
32
+ const values = Object.values(GL);
33
+ expect(new Set(values).size).toBe(values.length);
34
+ });
35
+
36
+ it('MECE keys are present (presence, count, isDelimiting, multiplicity, certainty)', () => {
37
+ expect(GL.PRESENCE).toBe('gl:presence');
38
+ expect(GL.COUNT).toBe('gl:count');
39
+ expect(GL.IS_DELIMITING).toBe('gl:isDelimiting');
40
+ expect(GL.MULTIPLICITY).toBe('gl:multiplicity');
41
+ expect(GL.CERTAINTY).toBe('gl:certainty');
42
+ });
43
+
44
+ it('JSON-LD core keys are present', () => {
45
+ expect(GL.ID).toBe('@id');
46
+ expect(GL.TYPE).toBe('@type');
47
+ });
48
+
49
+ it('LOCAL_ID is distinct from ID (one is gl:id, the other @id)', () => {
50
+ // Both represent "id" but in different namespaces:
51
+ // GL.ID (@id) — JSON-LD IRI for the concept
52
+ // GL.LOCAL_ID (gl:id) — source-local id within ConceptSource/Origin/Ref
53
+ expect(GL.ID).toBe('@id');
54
+ expect(GL.LOCAL_ID).toBe('gl:id');
55
+ expect(GL.ID).not.toBe(GL.LOCAL_ID);
56
+ });
57
+
58
+ it('count matches documented set (no drift)', () => {
59
+ // Update this number when adding a new key.
60
+ // The point of this test: when adding a key, you remember to update
61
+ // the count; when removing, you notice the test fails. Catches
62
+ // accidental drift in either direction.
63
+ expect(Object.keys(GL).length).toBe(90);
64
+ });
65
+ });
@@ -53,6 +53,15 @@ export function mapHyperedgeFromJsonLd(
53
53
  }
54
54
  }
55
55
  if (m[GL.IS_DELIMITING] === true) out.is_delimiting = true;
56
+ // ISO 704:2022 §5.5.4.2.1 — GenericMember carries a required
57
+ // delimitingCharacteristic (the intension difference between
58
+ // this specific concept and its coordinate concepts).
59
+ const dc = m[GL.DELIMITING_CHARACTERISTIC];
60
+ if (dc) {
61
+ out.delimitingCharacteristic = typeof dc === 'string'
62
+ ? { default: dc }
63
+ : dc;
64
+ }
56
65
  return out;
57
66
  })
58
67
  .filter((m): m is Record<string, unknown> => m !== null);
@@ -182,22 +182,37 @@ declare module 'glossarist' {
182
182
  ref?: ConceptRef;
183
183
  presence?: PartitivePresence;
184
184
  count?: PartitiveCount;
185
- is_delimiting?: boolean;
186
185
  });
187
186
  readonly ref: ConceptRef;
188
187
  readonly presence: PartitivePresence;
189
188
  readonly count: PartitiveCount;
190
- readonly is_delimiting: boolean;
191
- readonly isRequired: boolean;
192
- readonly isOptional: boolean;
193
- get isDelimiting(): boolean;
189
+ required(): boolean;
190
+ optional(): boolean;
194
191
  toJSON(): Record<string, unknown>;
195
192
  static fromJSON(data: Record<string, unknown>): HyperedgeMember;
196
193
  static identityOf(value: unknown): string;
197
194
  }
198
195
 
199
- export class PartitiveMember extends HyperedgeMember {}
200
- export class GenericMember extends HyperedgeMember {}
196
+ export class PartitiveMember extends HyperedgeMember {
197
+ constructor(data?: {
198
+ ref?: ConceptRef;
199
+ presence?: PartitivePresence;
200
+ count?: PartitiveCount;
201
+ is_delimiting?: boolean;
202
+ });
203
+ readonly is_delimiting: boolean;
204
+ get isDelimiting(): boolean;
205
+ }
206
+
207
+ export class GenericMember extends HyperedgeMember {
208
+ constructor(data?: {
209
+ ref?: ConceptRef;
210
+ presence?: PartitivePresence;
211
+ count?: PartitiveCount;
212
+ delimitingCharacteristic?: Record<string, string> | string;
213
+ });
214
+ readonly delimitingCharacteristic: Record<string, string>;
215
+ }
201
216
 
202
217
  export class AbstractHyperedge extends GlossaristModel {
203
218
  readonly comprehensive: ConceptRef;
@@ -200,7 +200,10 @@ export interface GenericMemberWire {
200
200
  uri: string;
201
201
  presence: 'required' | 'optional';
202
202
  count: 'exactly_one' | 'at_least_one' | 'multiple';
203
- isDelimiting: boolean;
203
+ /** ISO 704:2022 §5.5.4.2.1 — the delimiting characteristic that
204
+ * distinguishes this specific concept from its coordinate concepts.
205
+ * Required on every GenericMember. LocalizedString shape. */
206
+ delimitingCharacteristic: Record<string, string>;
204
207
  }
205
208
 
206
209
  export interface GraphNode {
@@ -134,6 +134,7 @@ export const GL = {
134
134
  PRESENCE: 'gl:presence',
135
135
  COUNT: 'gl:count',
136
136
  IS_DELIMITING: 'gl:isDelimiting',
137
+ DELIMITING_CHARACTERISTIC: 'gl:delimitingCharacteristic',
137
138
  MULTIPLICITY: 'gl:multiplicity',
138
139
  CERTAINTY: 'gl:certainty',
139
140
 
@@ -42,12 +42,17 @@ function designationFor(uri: string): string {
42
42
  return resolveDesignation(uri, store, factory, locale.value);
43
43
  }
44
44
 
45
+ /** Resolve a LocalizedString delimitingCharacteristic to the current locale. */
46
+ function characteristicText(dc: Record<string, string>): string {
47
+ return dc[locale.value] ?? dc.default ?? dc.eng ?? Object.values(dc)[0] ?? '';
48
+ }
49
+
45
50
  function memberLabel(member: GenericRelationWire['members'][number]): PartitiveMemberLabeled {
46
51
  return {
47
52
  uri: member.uri,
48
53
  presence: member.presence,
49
54
  count: member.count,
50
- isDelimiting: member.isDelimiting,
55
+ isDelimiting: false,
51
56
  label: designationFor(member.uri),
52
57
  };
53
58
  }
@@ -78,12 +83,15 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM
78
83
  :completeness="rel.completeness"
79
84
  />
80
85
 
81
- <ul class="member-presence-list" aria-label="Member presence and count">
86
+ <ul class="member-presence-list" aria-label="Member delimiting characteristics">
82
87
  <li v-for="(m, j) in rel.members" :key="j">
83
88
  <span class="member-label">{{ designationFor(m.uri) }}</span>
89
+ <span
90
+ v-if="m.delimitingCharacteristic"
91
+ class="member-delimiting-characteristic"
92
+ >{{ characteristicText(m.delimitingCharacteristic) }}</span>
84
93
  <span v-if="m.presence && m.presence !== 'required'" class="member-presence">{{ presenceLabel(m.presence) }}</span>
85
94
  <span v-if="m.count && m.count !== 'exactly_one'" class="member-count">{{ countLabel(m.count) }}</span>
86
- <span v-if="m.isDelimiting" class="member-delimiting">{{ t('relations.delimiting') }}</span>
87
95
  </li>
88
96
  </ul>
89
97
  </div>
@@ -171,18 +171,24 @@ export function useConceptEdges(
171
171
  const source = conceptUriValue.value;
172
172
  const comprehensive = resolveConceptRefUri(rel.comprehensive);
173
173
  if (!comprehensive) return null;
174
- const members: PartitiveMemberWire[] = (rel.members ?? rel.partitives ?? [])
175
- .map((m: any): PartitiveMemberWire | null => {
174
+ const isGeneric = rel instanceof GenericHyperedge;
175
+ const members: any[] = (rel.members ?? rel.partitives ?? [])
176
+ .map((m: any): any | null => {
176
177
  const uri = resolveConceptRefUri(m.ref);
177
178
  if (!uri || uri === source) return null;
178
- return {
179
+ const base: any = {
179
180
  uri,
180
181
  presence: readPresence(m),
181
182
  count: readCount(m),
182
- isDelimiting: readIsDelimiting(m),
183
183
  };
184
+ if (isGeneric) {
185
+ base.delimitingCharacteristic = m.delimitingCharacteristic;
186
+ } else {
187
+ base.isDelimiting = readIsDelimiting(m);
188
+ }
189
+ return base;
184
190
  })
185
- .filter((m: PartitiveMemberWire | null): m is PartitiveMemberWire => m !== null);
191
+ .filter((m: any | null): m is any => m !== null);
186
192
  if (members.length === 0) return null;
187
193
  return {
188
194
  source,
@@ -89,7 +89,7 @@ const partitiveRelationsForSphere = computed<PartitiveRelationWire[]>(() => {
89
89
  if (uri === sourceUri) return null;
90
90
  const presence: PartitivePresence = member.presence;
91
91
  const count: PartitiveCount = member.count;
92
- const isDelimiting = member.isDelimiting;
92
+ const isDelimiting = (member as any).is_delimiting === true;
93
93
  return { uri, presence, count, isDelimiting };
94
94
  })
95
95
  .filter((x): x is PartitiveMemberWire => x !== null);