@glossarist/concept-browser 0.7.93 → 0.7.94

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.93",
3
+ "version": "0.7.94",
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": {
@@ -204,6 +204,35 @@ function withdrawnConcept(): Concept {
204
204
  });
205
205
  }
206
206
 
207
+ function withPartitiveHyperedge(): Concept {
208
+ const source = 'urn:glossarist:fixtures';
209
+ return Concept.fromJSON({
210
+ id: '1.3',
211
+ uri: `${BASE}/with-partitive-hyperedge`,
212
+ status: 'valid',
213
+ localizations: {
214
+ eng: {
215
+ language_code: 'eng',
216
+ entry_status: 'valid',
217
+ terms: [{ type: 'expression', designation: 'system of quantities', normative_status: 'preferred' }],
218
+ definition: [{ content: 'A concept that has a partitive decomposition.' }],
219
+ },
220
+ },
221
+ relations: [
222
+ {
223
+ type: 'partitive_relation',
224
+ comprehensive: { source, id: '1.3' },
225
+ members: [
226
+ { ref: { source, id: '1.22' }, presence: 'required', count: 'at_least_one' },
227
+ { ref: { source, id: '1.4' }, presence: 'required', count: 'multiple' },
228
+ { ref: { source, id: '1.5' }, presence: 'required', count: 'at_least_one' },
229
+ ],
230
+ completeness: 'complete',
231
+ },
232
+ ],
233
+ });
234
+ }
235
+
207
236
  export const CONCEPT_FIXTURES: readonly ConceptFixture[] = [
208
237
  { name: 'minimal', description: 'one localization, one designation', uri: `${BASE}/minimal`, concept: minimal() },
209
238
  { name: 'multilingual', description: 'three languages across Latn and CJK scripts', uri: `${BASE}/multilingual`, concept: multilingual() },
@@ -212,6 +241,7 @@ export const CONCEPT_FIXTURES: readonly ConceptFixture[] = [
212
241
  { name: 'with-non-verbal', description: 'figure, formula, and table non-verbal reps', uri: `${BASE}/with-non-verbal`, concept: withNonVerbal() },
213
242
  { name: 'with-dates', description: 'accepted/amended/retired lifecycle', uri: `${BASE}/with-dates`, concept: withDates() },
214
243
  { name: 'withdrawn', description: 'withdrawn status with retirement date (J3)', uri: `${BASE}/withdrawn`, concept: withdrawnConcept() },
244
+ { name: 'with-partitive-hyperedge', description: 'partitive decomposition in concept.relations', uri: `${BASE}/with-partitive-hyperedge`, concept: withPartitiveHyperedge() },
215
245
  ];
216
246
 
217
247
  export function fixtureByName(name: string): ConceptFixture {
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { computed, ref } from 'vue';
3
+ import type { Router } from 'vue-router';
4
+ import { useConceptEdges } from '../../composables/use-concept-edges';
5
+ import { useVocabularyStore } from '../../stores/vocabulary';
6
+ import type { Manifest, GraphEdge } from '../../adapters/types';
7
+ import { makeManifest, setupPinia } from '../test-helpers';
8
+ import { fixtureByName } from '../__fixtures__/concepts';
9
+ import { getFactory, resetFactory } from '../../adapters/factory';
10
+
11
+ /**
12
+ * Regression test for the sphere-view hyperedge projection.
13
+ *
14
+ * Bug (2026-07-30): ConceptView.vue read `concept.partitiveRelations`
15
+ * — a field glossarist 0.4.34 REMOVED. The field was silently
16
+ * `undefined`, the sphere rendered no rake, and no test caught it.
17
+ *
18
+ * Fix: ConceptView consumes `useConceptEdges.conceptPartitiveRelations`,
19
+ * which reads the unified `concept.relations` array and filters by
20
+ * `instanceof PartitiveHyperedge`. This spec pins that contract —
21
+ * if glossarist ever renames or removes `.relations`, this fails.
22
+ */
23
+ describe('useConceptEdges — hyperedge projection (regression)', () => {
24
+ const URN_SOURCE = 'urn:glossarist:fixtures';
25
+ const manifest: Manifest = makeManifest({
26
+ id: 'fixtures',
27
+ datasetUri: URN_SOURCE,
28
+ uriBase: 'https://glossarist.org',
29
+ });
30
+ const noEdges: GraphEdge[] = [];
31
+ const noopRouter = { push() {} } as unknown as Router;
32
+
33
+ let store: ReturnType<typeof useVocabularyStore>;
34
+
35
+ beforeEach(() => {
36
+ setupPinia();
37
+ resetFactory();
38
+ store = useVocabularyStore();
39
+ store.manifests.set(manifest.id, manifest);
40
+
41
+ const factory = getFactory();
42
+ factory.resolveRelatedRef = vi.fn((ref: { source: string | null; id: string | null } | null) =>
43
+ ref?.source === URN_SOURCE && ref.id
44
+ ? { registerId: manifest.id, conceptId: ref.id }
45
+ : null,
46
+ );
47
+ });
48
+
49
+ it('projects a PartitiveHyperedge from concept.relations', () => {
50
+ const { concept } = fixtureByName('with-partitive-hyperedge');
51
+ const { conceptPartitiveRelations } = useConceptEdges(
52
+ computed(() => concept),
53
+ computed(() => 'fixtures'),
54
+ computed(() => manifest),
55
+ computed(() => noEdges),
56
+ noopRouter,
57
+ );
58
+
59
+ const rels = conceptPartitiveRelations.value;
60
+ expect(rels).toHaveLength(1);
61
+
62
+ const [rel] = rels;
63
+ expect(rel.comprehensive).toBe('https://glossarist.org/fixtures/concept/1.3');
64
+ expect(rel.partitives).toHaveLength(3);
65
+ expect(rel.partitives.map(m => m.uri)).toEqual([
66
+ 'https://glossarist.org/fixtures/concept/1.22',
67
+ 'https://glossarist.org/fixtures/concept/1.4',
68
+ 'https://glossarist.org/fixtures/concept/1.5',
69
+ ]);
70
+ expect(rel.partitives[1].presence).toBe('required');
71
+ expect(rel.partitives[1].count).toBe('multiple');
72
+ });
73
+
74
+ it('returns [] when concept is null (loading state)', () => {
75
+ const { conceptPartitiveRelations, conceptGenericRelations } = useConceptEdges(
76
+ computed(() => null),
77
+ computed(() => 'fixtures'),
78
+ computed(() => manifest),
79
+ computed(() => noEdges),
80
+ noopRouter,
81
+ );
82
+ expect(conceptPartitiveRelations.value).toEqual([]);
83
+ expect(conceptGenericRelations.value).toEqual([]);
84
+ });
85
+
86
+ it('returns [] when manifest is null', () => {
87
+ const { concept } = fixtureByName('with-partitive-hyperedge');
88
+ const { conceptPartitiveRelations } = useConceptEdges(
89
+ computed(() => concept),
90
+ computed(() => 'fixtures'),
91
+ computed(() => null),
92
+ computed(() => noEdges),
93
+ noopRouter,
94
+ );
95
+ expect(conceptPartitiveRelations.value).toEqual([]);
96
+ });
97
+
98
+ it('would have caught the .partitiveRelations regression', () => {
99
+ // Simulate the pre-fix behavior: the old code read concept.partitiveRelations
100
+ // (undefined in glossarist 0.4.34+) and produced an empty array.
101
+ // This spec proves the projection now reads concept.relations instead.
102
+ const { concept } = fixtureByName('with-partitive-hyperedge');
103
+ expect((concept as any).partitiveRelations).toBeUndefined();
104
+ expect((concept as any).relations).toHaveLength(1);
105
+
106
+ const { conceptPartitiveRelations } = useConceptEdges(
107
+ computed(() => concept),
108
+ computed(() => 'fixtures'),
109
+ computed(() => manifest),
110
+ computed(() => noEdges),
111
+ noopRouter,
112
+ );
113
+ expect(conceptPartitiveRelations.value.length).toBe(1);
114
+ });
115
+ });
@@ -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 new PartitiveMemberModel(data as any) as PartitiveMember;
42
+ return new (PartitiveMemberModel as unknown as new (data: unknown) => PartitiveMember)(data) 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 new PartitiveHyperedgeModel(opts as any) as PartitiveHyperedge;
51
+ return new (PartitiveHyperedgeModel as unknown as new (data: unknown) => PartitiveHyperedge)(opts) as PartitiveHyperedge;
52
52
  }
53
53
 
54
54
  describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
@@ -171,8 +171,6 @@ declare module 'glossarist' {
171
171
 
172
172
  interface Concept {
173
173
  readonly relations: AbstractHyperedge[];
174
- readonly partitiveRelations: PartitiveHyperedge[];
175
- readonly genericRelations: GenericHyperedge[];
176
174
  }
177
175
 
178
176
  // Re-exposed here so callers importing from the top-level `glossarist`
@@ -10,12 +10,14 @@ import { useRdfDocument } from './use-rdf-document';
10
10
  const props = defineProps<{
11
11
  concept: Concept;
12
12
  registerId: string;
13
- conceptUriValue: string;
13
+ conceptUriValue: string | null;
14
14
  }>();
15
15
 
16
+ const uri = computed(() => props.conceptUriValue ?? '');
17
+
16
18
  const { sections, turtle, jsonld, typeChain } = useRdfDocument(
17
19
  () => props.concept,
18
- () => props.conceptUriValue,
20
+ () => uri.value,
19
21
  );
20
22
 
21
23
  const resourceCount = computed(() => sections.value.length);
@@ -23,11 +25,11 @@ const conceptId = computed(() => props.concept.id);
23
25
  </script>
24
26
 
25
27
  <template>
26
- <ErrorBoundary title="RDF view failed" :retry-key="conceptUriValue">
28
+ <ErrorBoundary title="RDF view failed" :retry-key="uri">
27
29
  <div class="space-y-6">
28
30
  <div class="card p-5">
29
31
  <div class="flex items-start justify-between gap-3">
30
- <RdfInstanceHeader :uri="conceptUriValue" :concept-id="conceptId" />
32
+ <RdfInstanceHeader :uri="uri" :concept-id="conceptId" />
31
33
  </div>
32
34
  <div class="mt-4 pt-3 border-t border-ink-100/60">
33
35
  <div class="flex items-center gap-1.5 flex-wrap text-xs text-ink-400">
@@ -39,9 +39,9 @@ export interface EdgeDisplay {
39
39
  }
40
40
 
41
41
  export function useConceptEdges(
42
- concept: ComputedRef<Concept>,
42
+ concept: ComputedRef<Concept | null | undefined>,
43
43
  registerId: ComputedRef<string>,
44
- manifest: ComputedRef<Manifest>,
44
+ manifest: ComputedRef<Manifest | null | undefined>,
45
45
  edges: ComputedRef<GraphEdge[]>,
46
46
  router: Router,
47
47
  ) {
@@ -50,24 +50,30 @@ export function useConceptEdges(
50
50
  const { getColor } = useDsStyle();
51
51
  const { locale } = useI18n();
52
52
 
53
- const conceptUriValue = computed(() =>
54
- conceptUri(concept.value, registerId.value, manifest.value.uriBase)
55
- );
53
+ const conceptUriValue = computed(() => {
54
+ if (!concept.value || !manifest.value) return null;
55
+ return conceptUri(concept.value, registerId.value, manifest.value.uriBase);
56
+ });
56
57
 
57
- const outgoingEdges = computed(() =>
58
- store.graph.getUniqueEdges(conceptUriValue.value, 'outgoing', 'target')
59
- .filter(e => e.type !== 'domain' && e.type !== 'section')
60
- );
58
+ const outgoingEdges = computed(() => {
59
+ const uri = conceptUriValue.value;
60
+ if (!uri) return [];
61
+ return store.graph.getUniqueEdges(uri, 'outgoing', 'target')
62
+ .filter(e => e.type !== 'domain' && e.type !== 'section');
63
+ });
61
64
 
62
- const incomingEdges = computed(() =>
63
- store.graph.getUniqueEdges(conceptUriValue.value, 'incoming', 'source')
64
- .filter(e => e.type !== 'domain' && e.type !== 'section')
65
- );
65
+ const incomingEdges = computed(() => {
66
+ const uri = conceptUriValue.value;
67
+ if (!uri) return [];
68
+ return store.graph.getUniqueEdges(uri, 'incoming', 'source')
69
+ .filter(e => e.type !== 'domain' && e.type !== 'section');
70
+ });
66
71
 
67
72
  const edgeDisplayCache = computed(() => {
68
73
  const cache = new Map<string, EdgeDisplay>();
74
+ const focusUri = conceptUriValue.value;
69
75
  for (const e of edges.value) {
70
- const uri = e.source === conceptUriValue.value ? e.target : e.source;
76
+ const uri = e.source === focusUri ? e.target : e.source;
71
77
  if (cache.has(uri)) continue;
72
78
  const resolution = factory.resolve(uri, registerId.value);
73
79
  const isLocal = resolution.type === 'internal' && resolution.registerId === registerId.value;
@@ -131,6 +137,7 @@ export function useConceptEdges(
131
137
 
132
138
  // Concept-level related concepts (managed concept cross-references)
133
139
  const conceptRelated = computed(() => {
140
+ if (!concept.value) return [];
134
141
  const direct = concept.value.relatedConcepts?.filter(rc => !INVERSE_RELATIONSHIPS[rc.type]) ?? [];
135
142
  const derived = incomingEdges.value
136
143
  .filter(e => INVERSE_RELATIONSHIPS[e.type])
@@ -169,6 +176,7 @@ export function useConceptEdges(
169
176
  // discrimination is via instanceof.
170
177
  function projectHyperedge(rel: any): PartitiveRelationWire | GenericRelationWire | null {
171
178
  const source = conceptUriValue.value;
179
+ if (!source) return null;
172
180
  const comprehensive = resolveConceptRefUri(rel.comprehensive);
173
181
  if (!comprehensive) return null;
174
182
  const isGeneric = rel instanceof GenericHyperedge;
@@ -202,14 +210,16 @@ export function useConceptEdges(
202
210
  }
203
211
 
204
212
  const conceptPartitiveRelations = computed<PartitiveRelationWire[]>(() => {
205
- return (concept.value.relations as any[] ?? [])
213
+ if (!concept.value) return [];
214
+ return ((concept.value.relations as any[]) ?? [])
206
215
  .filter(r => r instanceof PartitiveHyperedge)
207
216
  .map(projectHyperedge)
208
217
  .filter((r): r is PartitiveRelationWire => r !== null);
209
218
  });
210
219
 
211
220
  const conceptGenericRelations = computed<GenericRelationWire[]>(() => {
212
- return (concept.value.relations as any[] ?? [])
221
+ if (!concept.value) return [];
222
+ return ((concept.value.relations as any[]) ?? [])
213
223
  .filter(r => r instanceof GenericHyperedge)
214
224
  .map(projectHyperedge)
215
225
  .filter((r): r is GenericRelationWire => r !== null);
@@ -2,14 +2,10 @@
2
2
  import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
3
3
  import { useRouter } from 'vue-router';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
- import { conceptUri } from '../adapters/model-bridge';
6
- import { UriRouter } from '../adapters/UriRouter';
7
- import type { PartitiveRelationWire, PartitiveMemberWire } from '../adapters/types';
8
- import type { PartitiveMember } from 'glossarist/models';
9
- import type { PartitivePresence, PartitiveCount } from '../utils/partitive-multiplicity';
10
5
  import ConceptDetail from '../components/ConceptDetail.vue';
11
6
  import RelationSphere from '../components/RelationSphere.vue';
12
7
  import ShortcutsModal from '../components/ShortcutsModal.vue';
8
+ import { useConceptEdges } from '../composables/use-concept-edges';
13
9
  import { useI18n } from '../i18n';
14
10
 
15
11
  const { t } = useI18n();
@@ -63,47 +59,13 @@ const edges = computed(() => store.conceptEdges);
63
59
  const activeRegisterId = computed(() => sphereFocusPayload.value?.registerId ?? props.registerId);
64
60
  const adjacent = ref({ prev: null as string | null, next: null as string | null });
65
61
 
66
- /**
67
- * Project concept.partitiveRelations (glossarist-js v2 model) into the
68
- * URI-resolved wire shape the sphere consumes. Mirrors the projection
69
- * in use-concept-edges.ts but inline here so the sphere can render rake
70
- * bundles without a full composable setup.
71
- */
72
- const partitiveRelationsForSphere = computed<PartitiveRelationWire[]>(() => {
73
- const c = concept.value;
74
- const m = manifest.value;
75
- if (!c || !m) return [];
76
- const sourceUri = conceptUri(c, activeRegisterId.value, m.uriBase);
77
- const out: PartitiveRelationWire[] = [];
78
- for (const rel of c.partitiveRelations ?? []) {
79
- const compRef = rel.comprehensive;
80
- const compTarget = store.manifests.get(activeRegisterId.value)
81
- ? UriRouter.buildConceptUri(m.uriBase, activeRegisterId.value, compRef.id ?? '')
82
- : null;
83
- if (!compRef.id || !compTarget) continue;
84
- const partitives: PartitiveMemberWire[] = rel.partitives
85
- .map((member): PartitiveMemberWire | null => {
86
- const ref = member.ref;
87
- if (!ref?.id) return null;
88
- const uri = UriRouter.buildConceptUri(m.uriBase, activeRegisterId.value, ref.id);
89
- if (uri === sourceUri) return null;
90
- const presence: PartitivePresence = member.presence;
91
- const count: PartitiveCount = member.count;
92
- const isDelimiting = (member as any).is_delimiting === true;
93
- return { uri, presence, count, isDelimiting };
94
- })
95
- .filter((x): x is PartitiveMemberWire => x !== null);
96
- if (partitives.length === 0) continue;
97
- out.push({
98
- source: sourceUri,
99
- comprehensive: compTarget,
100
- partitives,
101
- completeness: rel.completeness,
102
- register: activeRegisterId.value,
103
- });
104
- }
105
- return out;
106
- });
62
+ const { conceptPartitiveRelations } = useConceptEdges(
63
+ concept,
64
+ activeRegisterId,
65
+ manifest,
66
+ edges,
67
+ router,
68
+ );
107
69
  const viewMode = ref<'detail' | 'sphere'>(
108
70
  router.currentRoute.value.query.view === 'sphere' ? 'sphere' : 'detail'
109
71
  );
@@ -326,7 +288,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
326
288
  :manifest="manifest"
327
289
  :register-id="activeRegisterId"
328
290
  :edges="edges"
329
- :partitive-relations="partitiveRelationsForSphere"
291
+ :partitive-relations="conceptPartitiveRelations"
330
292
  @navigate="onSphereNavigate"
331
293
  />
332
294
  <!-- Detail mode -->