@glossarist/concept-browser 0.7.31 → 0.7.33

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.31",
3
+ "version": "0.7.33",
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": {
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Usage: node scripts/build-edges.js
7
7
  */
8
+ import { extractSourceRefs } from './extract-source-refs.js';
8
9
  import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
9
10
  import { join, dirname } from 'path';
10
11
  import { fileURLToPath } from 'url';
@@ -129,13 +130,14 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
129
130
  const conceptsDir = join(datasetDir, 'concepts');
130
131
  if (!existsSync(conceptsDir)) {
131
132
  console.log(` Skipping ${registerId}: no concepts directory`);
132
- return [];
133
+ return { edges: [], sourceRefs: [] };
133
134
  }
134
135
 
135
136
  const files = readdirSync(conceptsDir).filter(f => f.endsWith('.json'));
136
137
  console.log(` Processing ${files.length} concepts...`);
137
138
 
138
139
  const allEdges = [];
140
+ const allSourceRefs = [];
139
141
  const domainConceptCount = new Map();
140
142
  let processed = 0;
141
143
 
@@ -144,6 +146,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
144
146
  const data = JSON.parse(readFileSync(join(conceptsDir, file), 'utf-8'));
145
147
  const edges = extractAllEdges(data, registerId, uriBase, urnMap);
146
148
  allEdges.push(...edges);
149
+ allSourceRefs.push(...extractSourceRefs(data, registerId));
147
150
 
148
151
  for (const edge of edges) {
149
152
  if (edge.type === 'domain' || edge.type === 'section') {
@@ -241,7 +244,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
241
244
  console.log(` Written ${domainNodes.length} edge-derived domain nodes to domain-nodes.json`);
242
245
  }
243
246
 
244
- return deduped;
247
+ return { edges: deduped, sourceRefs: allSourceRefs };
245
248
  }
246
249
 
247
250
  // Main
@@ -278,6 +281,7 @@ for (const ds of datasets) {
278
281
  console.log(`URN resolution map: ${[...urnMap.entries()].map(([k,v]) => `${k}→${v}`).join(', ')}\n`);
279
282
 
280
283
  const allDatasetEdges = new Map();
284
+ const allSourceRefs = [];
281
285
 
282
286
  for (const ds of datasets) {
283
287
  const manifest = manifestCache.get(ds);
@@ -285,14 +289,52 @@ for (const ds of datasets) {
285
289
  try {
286
290
  console.log(`${manifest.title} (${ds}):`);
287
291
  const uriBase = manifest.uriBase || 'https://glossarist.org';
288
- const edges = buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
289
- allDatasetEdges.set(ds, edges);
292
+ const result = buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
293
+ allDatasetEdges.set(ds, result.edges);
294
+ allSourceRefs.push(...result.sourceRefs);
290
295
  } catch (e) {
291
296
  console.error(`Error reading manifest for ${ds}: ${e.message}`);
292
297
  }
293
298
  console.log();
294
299
  }
295
300
 
301
+ // Audit — report concept source strings not covered by manifest refs.
302
+ // Bibliography mapping (ref → URN) is declared in register.yaml and flows
303
+ // through manifest.json to datasets.json at generate time. No separate
304
+ // source-refs file needed — the registry IS the single source of truth.
305
+
306
+ // Build a lookup of all known source strings from manifests
307
+ const knownSourceStrings = new Set();
308
+ for (const [ds, manifest] of manifestCache) {
309
+ if (manifest.ref) knownSourceStrings.add(manifest.ref);
310
+ for (const alias of manifest.refAliases ?? []) {
311
+ knownSourceStrings.add(alias);
312
+ }
313
+ if (manifest.datasetUri) knownSourceStrings.add(manifest.datasetUri);
314
+ for (const alias of manifest.uriAliases ?? []) {
315
+ const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
316
+ knownSourceStrings.add(base);
317
+ }
318
+ }
319
+
320
+ const auditUnmatched = new Map();
321
+ for (const { source, registerId } of allSourceRefs) {
322
+ if (knownSourceStrings.has(source)) continue;
323
+ // URN source strings resolve via URI routing — not a bibliography concern
324
+ if (source.startsWith('urn:')) continue;
325
+ if (!auditUnmatched.has(source)) {
326
+ auditUnmatched.set(source, registerId);
327
+ }
328
+ }
329
+
330
+ if (auditUnmatched.size > 0) {
331
+ console.warn(`\n⚠ ${auditUnmatched.size} source string(s) in concept data have no matching dataset ref:`);
332
+ for (const [source, fromDataset] of auditUnmatched) {
333
+ console.warn(` "${source}" (from ${fromDataset})`);
334
+ }
335
+ console.warn('Add refAliases to the target dataset manifest, or fix source strings to use URNs.\n');
336
+ }
337
+
296
338
  // Build cross-reference index: for each dataset, which other datasets'
297
339
  // edges.json contains edges targeting that dataset's URIs.
298
340
  const datasetUriPrefixes = new Map();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Source reference extraction for citation linking.
3
+ *
4
+ * Scans concept JSON-LD for source strings in gl:source fields.
5
+ * Used by build-edges.js to generate source-refs.json.
6
+ */
7
+
8
+ export function extractSourceRefs(concept, registerId) {
9
+ const refs = new Set();
10
+
11
+ // Managed concept-level sources
12
+ for (const src of concept['gl:source'] || []) {
13
+ const origin = src['gl:origin'];
14
+ if (origin) {
15
+ const ref = origin['gl:ref'];
16
+ if (ref?.['gl:source']) refs.add(ref['gl:source']);
17
+ }
18
+ }
19
+
20
+ // Localized concept-level sources
21
+ for (const lc of Object.values(concept['gl:localizedConcept'] || {})) {
22
+ for (const src of lc['gl:source'] || []) {
23
+ const origin = src['gl:origin'];
24
+ if (origin) {
25
+ const ref = origin['gl:ref'];
26
+ if (ref?.['gl:source']) refs.add(ref['gl:source']);
27
+ }
28
+ }
29
+ }
30
+
31
+ return [...refs].map(source => ({ source, registerId }));
32
+ }
@@ -927,6 +927,8 @@ for (let i = 0; i < config.datasets.length; i++) {
927
927
  datasetUri: ds.uri || reg?.urn || undefined,
928
928
  uriBase: config.uriBase || undefined,
929
929
  uriAliases: ds.uriAliases || reg?.urnAliases || undefined,
930
+ ref: ds.ref || reg?.ref || undefined,
931
+ refAliases: ds.refAliases || reg?.refAliases || undefined,
930
932
  });
931
933
  }
932
934
  writeJson(path.join(PUBLIC, 'datasets.json'), registry);
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import { createRouter, createMemoryHistory } from 'vue-router';
4
+ import { createPinia, setActivePinia } from 'pinia';
5
+ import CitationDisplay from '../components/CitationDisplay.vue';
6
+ import { getFactory, resetFactory } from '../adapters/factory';
7
+ import { ReferenceResolver } from '../adapters/ReferenceResolver';
8
+
9
+ // Minimal Citation type matching the glossarist Citation interface
10
+ function makeCitation(source: string, referenceFrom: string, type = 'clause') {
11
+ return {
12
+ ref: { source },
13
+ locality: { type, referenceFrom },
14
+ };
15
+ }
16
+
17
+ describe('CitationDisplay — source reference linking', () => {
18
+ let router: any;
19
+
20
+ beforeEach(async () => {
21
+ resetFactory();
22
+ const factory = getFactory();
23
+ // Register dataset patterns
24
+ factory.resolver.registerDataset('vim-2012', ['urn:oiml:pub:v:2:2012*']);
25
+ factory.resolver.registerDataset('viml-2022', ['urn:oiml:pub:v:1:2022*']);
26
+ // Register source refs
27
+ factory.resolver.registerSourceRef('OIML V 2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
28
+ factory.resolver.registerSourceRef('OIML V2-200:2012', 'vim-2012', 'urn:oiml:pub:v:2:2012');
29
+ factory.resolver.registerSourceRef('VIM', 'vim-2012', 'urn:oiml:pub:v:2:2012');
30
+
31
+ router = createRouter({
32
+ history: createMemoryHistory(),
33
+ routes: [
34
+ { path: '/', component: { template: '<div/>' } },
35
+ { name: 'concept', path: '/dataset/:registerId/concept/:conceptId', component: { template: '<div/>' } },
36
+ ],
37
+ });
38
+ await router.push('/');
39
+ setActivePinia(createPinia());
40
+ });
41
+
42
+ function mountCitation(citation: any, registerId?: string) {
43
+ return mount(CitationDisplay, {
44
+ props: { citation, registerId },
45
+ global: { plugins: [router] },
46
+ });
47
+ }
48
+
49
+ it('renders source text as plain span when citation cannot be resolved', () => {
50
+ const wrapper = mountCitation(makeCitation('Unknown Source', '1.1'));
51
+ expect(wrapper.find('button.concept-link').exists()).toBe(false);
52
+ expect(wrapper.text()).toContain('Unknown Source');
53
+ });
54
+
55
+ it('renders source text as clickable button when citation resolves', () => {
56
+ const wrapper = mountCitation(makeCitation('OIML V2-200:2012', '2.2'), 'viml-2022');
57
+ expect(wrapper.find('button.concept-link').exists()).toBe(true);
58
+ expect(wrapper.text()).toContain('OIML V2-200:2012');
59
+ });
60
+
61
+ it('renders locality as clickable when citation resolves', () => {
62
+ const wrapper = mountCitation(makeCitation('OIML V2-200:2012', '2.2'), 'viml-2022');
63
+ expect(wrapper.text()).toContain('2.2');
64
+ // The clause type and referenceFrom should be inside a button
65
+ const buttons = wrapper.findAll('button.concept-link');
66
+ expect(buttons.length).toBeGreaterThanOrEqual(1);
67
+ });
68
+
69
+ it('renders locality as plain text when citation does not resolve', () => {
70
+ const wrapper = mountCitation(makeCitation('Unknown', '1.1'));
71
+ expect(wrapper.text()).toContain('1.1');
72
+ expect(wrapper.find('button.concept-link').exists()).toBe(false);
73
+ });
74
+
75
+ it('renders nothing when citation has no ref', () => {
76
+ const wrapper = mountCitation({ ref: null, locality: null });
77
+ expect(wrapper.find('button').exists()).toBe(false);
78
+ });
79
+
80
+ it('renders ref.id when present alongside ref.source', () => {
81
+ const wrapper = mountCitation({
82
+ ref: { source: 'Unknown', id: 'ABC-123' },
83
+ locality: null,
84
+ });
85
+ expect(wrapper.text()).toContain('ABC-123');
86
+ });
87
+
88
+ it('renders ref.version when present', () => {
89
+ const wrapper = mountCitation({
90
+ ref: { source: 'ISO 9000', version: '2015' },
91
+ locality: null,
92
+ });
93
+ expect(wrapper.text()).toContain('2015');
94
+ });
95
+
96
+ it('shows target hint for resolved cross-dataset citation', () => {
97
+ const wrapper = mountCitation(makeCitation('VIM', '2.2'), 'viml-2022');
98
+ const hint = wrapper.text();
99
+ expect(hint).toContain('→');
100
+ });
101
+
102
+ it('resolves via ref alias', () => {
103
+ const wrapper = mountCitation(makeCitation('VIM', '2.2'), 'viml-2022');
104
+ expect(wrapper.find('button.concept-link').exists()).toBe(true);
105
+ });
106
+
107
+ it('renders citation with both source and locality resolved', () => {
108
+ const wrapper = mountCitation(makeCitation('OIML V 2-200:2012', '5.1'), 'viml-2022');
109
+ expect(wrapper.find('button.concept-link').exists()).toBe(true);
110
+ expect(wrapper.text()).toContain('OIML V 2-200:2012');
111
+ expect(wrapper.text()).toContain('5.1');
112
+ });
113
+
114
+ it('shows cross-dataset arrow indicator for cross-dataset citations', () => {
115
+ const wrapper = mountCitation(makeCitation('VIM', '2.2'), 'viml-2022');
116
+ expect(wrapper.text()).toContain('↗');
117
+ });
118
+
119
+ it('does not show cross-dataset arrow for same-dataset citations', () => {
120
+ // Same dataset: registerId matches resolved target
121
+ const wrapper = mountCitation(makeCitation('OIML V 2-200:2012', '2.2'), 'vim-2012');
122
+ expect(wrapper.text()).not.toContain('↗');
123
+ });
124
+
125
+ it('does not show cross-dataset arrow for unresolved citations', () => {
126
+ const wrapper = mountCitation(makeCitation('Unknown', '1.1'));
127
+ expect(wrapper.text()).not.toContain('↗');
128
+ });
129
+
130
+ it('renders hover preview elements for resolved citations', () => {
131
+ const wrapper = mountCitation(makeCitation('VIM', '2.2'), 'viml-2022');
132
+ // The component should have mouseenter handlers on the button
133
+ const buttons = wrapper.findAll('button.concept-link');
134
+ expect(buttons.length).toBeGreaterThanOrEqual(1);
135
+ // Teleport content is not rendered in test env, but the template structure is present
136
+ expect(wrapper.html()).toContain('Hover preview');
137
+ });
138
+
139
+ it('does not render preview elements for unresolved citations', () => {
140
+ const wrapper = mountCitation(makeCitation('Unknown', '1.1'));
141
+ expect(wrapper.html()).not.toContain('citation-preview');
142
+ });
143
+ });
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { LocalizedConcept, Expression } from 'glossarist';
3
+ import {
4
+ entryStatusColor,
5
+ conceptStatusColor,
6
+ conceptStatusLabel,
7
+ conceptStatusDefinition,
8
+ entryStatusLabel,
9
+ entryStatusDefinition,
10
+ getPreferredTerm,
11
+ } from '../utils/concept-helpers';
12
+ import { ontology } from '../adapters/ontology-registry';
13
+
14
+ describe('conceptStatusColor', () => {
15
+ it('returns correct color for each concept status', () => {
16
+ expect(conceptStatusColor('valid')).toBe('badge-green');
17
+ expect(conceptStatusColor('superseded')).toBe('bg-red-50 text-red-700');
18
+ expect(conceptStatusColor('draft')).toBe('badge-yellow');
19
+ expect(conceptStatusColor('retired')).toBe('badge-gray');
20
+ expect(conceptStatusColor('invalid')).toBe('bg-red-50 text-red-700');
21
+ });
22
+
23
+ it('returns badge-gray for null', () => {
24
+ expect(conceptStatusColor(null)).toBe('badge-gray');
25
+ });
26
+
27
+ it('returns badge-gray for unknown status', () => {
28
+ expect(conceptStatusColor('nonexistent')).toBe('badge-gray');
29
+ });
30
+ });
31
+
32
+ describe('conceptStatusLabel', () => {
33
+ it('returns label for each concept status', () => {
34
+ expect(conceptStatusLabel('valid')).toBe('valid');
35
+ expect(conceptStatusLabel('superseded')).toBe('superseded');
36
+ expect(conceptStatusLabel('retired')).toBe('retired');
37
+ expect(conceptStatusLabel('draft')).toBe('draft');
38
+ });
39
+
40
+ it('returns empty string for null', () => {
41
+ expect(conceptStatusLabel(null)).toBe('');
42
+ });
43
+
44
+ it('returns raw id for unknown status', () => {
45
+ expect(conceptStatusLabel('unknown_status')).toBe('unknown_status');
46
+ });
47
+ });
48
+
49
+ describe('conceptStatusDefinition', () => {
50
+ it('returns definition for known statuses', () => {
51
+ const def = conceptStatusDefinition('valid');
52
+ expect(def).toBeTruthy();
53
+ expect(typeof def).toBe('string');
54
+ });
55
+
56
+ it('returns null for null', () => {
57
+ expect(conceptStatusDefinition(null)).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('entryStatusColor', () => {
62
+ it('returns correct color for each entry status', () => {
63
+ expect(entryStatusColor('valid')).toBe('badge-green');
64
+ expect(entryStatusColor('not_valid')).toBe('bg-red-50 text-red-700');
65
+ expect(entryStatusColor('draft')).toBe('badge-yellow');
66
+ });
67
+
68
+ it('returns badge-gray for unknown status', () => {
69
+ expect(entryStatusColor('nonexistent')).toBe('badge-gray');
70
+ });
71
+ });
72
+
73
+ describe('entryStatusLabel', () => {
74
+ it('returns label for each entry status', () => {
75
+ expect(entryStatusLabel('valid')).toBe('valid');
76
+ expect(entryStatusLabel('not_valid')).toBe('not valid');
77
+ expect(entryStatusLabel('draft')).toBe('draft');
78
+ });
79
+
80
+ it('returns empty string for null', () => {
81
+ expect(entryStatusLabel(null)).toBe('');
82
+ });
83
+ });
84
+
85
+ describe('entryStatusDefinition', () => {
86
+ it('returns definition for known statuses', () => {
87
+ const def = entryStatusDefinition('valid');
88
+ expect(def).toBeTruthy();
89
+ });
90
+
91
+ it('returns null for null', () => {
92
+ expect(entryStatusDefinition(null)).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe('getPreferredTerm', () => {
97
+ it('returns primary designation when set', () => {
98
+ const lc = LocalizedConcept.fromJSON({
99
+ language_code: 'eng',
100
+ terms: [
101
+ { designation: 'preferred term', type: 'expression', normative_status: 'preferred' },
102
+ { designation: 'admitted term', type: 'expression', normative_status: 'admitted' },
103
+ ],
104
+ });
105
+ expect(getPreferredTerm(lc)).toBe('preferred term');
106
+ });
107
+
108
+ it('returns first term when no primary designation', () => {
109
+ const lc = LocalizedConcept.fromJSON({
110
+ language_code: 'eng',
111
+ terms: [{ designation: 'only term', type: 'expression' }],
112
+ });
113
+ expect(getPreferredTerm(lc)).toBe('only term');
114
+ });
115
+
116
+ it('returns fallback for null', () => {
117
+ expect(getPreferredTerm(null)).toBe('—');
118
+ expect(getPreferredTerm(null, 'N/A')).toBe('N/A');
119
+ });
120
+
121
+ it('returns fallback for undefined', () => {
122
+ expect(getPreferredTerm(undefined)).toBe('—');
123
+ });
124
+
125
+ it('returns fallback when no terms', () => {
126
+ const lc = LocalizedConcept.fromJSON({ language_code: 'eng' });
127
+ expect(getPreferredTerm(lc)).toBe('—');
128
+ });
129
+ });
@@ -42,7 +42,7 @@ describe('designation relationship bridge', () => {
42
42
 
43
43
  const rc = term.related[0];
44
44
  expect(rc.type).toBe('abbreviated_form_for');
45
- expect(getDesignationTarget(rc)).toBe('Portable Document Format');
45
+ expect(getDesignationTarget(rc as any)).toBe('Portable Document Format');
46
46
  });
47
47
 
48
48
  it('returns null for concept-level relationships (no target)', () => {
@@ -50,7 +50,7 @@ describe('designation relationship bridge', () => {
50
50
  const concept = conceptFromJson(doc);
51
51
  const rc = concept.localization('eng')!.terms[0].related[0];
52
52
  expect(rc.type).toBe('abbreviated_form_for');
53
- expect(getDesignationTarget(rc)).toBeNull();
53
+ expect(getDesignationTarget(rc as any)).toBeNull();
54
54
  });
55
55
 
56
56
  it('handles short_form_for designation target', () => {
@@ -58,7 +58,7 @@ describe('designation relationship bridge', () => {
58
58
  const concept = conceptFromJson(doc);
59
59
  const rc = concept.localization('eng')!.terms[0].related[0];
60
60
  expect(rc.type).toBe('short_form_for');
61
- expect(getDesignationTarget(rc)).toBe('kilogram');
61
+ expect(getDesignationTarget(rc as any)).toBe('kilogram');
62
62
  });
63
63
 
64
64
  it('preserves designation target from glossarist native format', () => {
@@ -78,7 +78,7 @@ describe('designation relationship bridge', () => {
78
78
  };
79
79
  const concept = conceptFromJson(doc);
80
80
  const rc = concept.localization('eng')!.terms[0].related[0];
81
- expect(getDesignationTarget(rc)).toBe('Portable Document Format');
81
+ expect(getDesignationTarget(rc as any)).toBe('Portable Document Format');
82
82
  });
83
83
 
84
84
  it('designation without related returns empty related array', () => {
@@ -171,6 +171,37 @@ describe('ConceptRef text bridge', () => {
171
171
  });
172
172
  });
173
173
 
174
+ // ── Robustness: matching by designation string, not array index ────────────
175
+
176
+ describe('designation target matching robustness', () => {
177
+ it('matches designation targets correctly even when raw terms are reordered', () => {
178
+ const doc = {
179
+ id: '1',
180
+ localizations: {
181
+ eng: {
182
+ language_code: 'eng',
183
+ terms: [
184
+ { designation: 'PDF', type: 'abbreviation', related: [{ type: 'abbreviated_form_for', target: 'Portable Document Format' }] },
185
+ { designation: 'ISO', type: 'abbreviation', related: [{ type: 'abbreviated_form_for', target: 'International Organization for Standardization' }] },
186
+ ],
187
+ definition: [{ content: 'test' }],
188
+ },
189
+ },
190
+ };
191
+ // Reverse the raw terms to simulate reordering
192
+ const rawTerms = doc.localizations.eng.terms.slice().reverse();
193
+ doc.localizations.eng.terms = rawTerms;
194
+
195
+ const concept = conceptFromJson(doc);
196
+ const lc = concept.localization('eng')!;
197
+ // Terms are in model order, not raw order
198
+ const pdf = lc.terms.find(t => t.designation === 'PDF')!;
199
+ const iso = lc.terms.find(t => t.designation === 'ISO')!;
200
+ expect(getDesignationTarget(pdf.related[0])).toBe('Portable Document Format');
201
+ expect(getDesignationTarget(iso.related[0])).toBe('International Organization for Standardization');
202
+ });
203
+ });
204
+
174
205
  // ── Generate-data serialization ────────────────────────────────────────────
175
206
 
176
207
  describe('generate-data designation serialization', () => {
@@ -0,0 +1,136 @@
1
+ // @ts-nocheck — ESM .js import without type declarations
2
+ import { describe, it, expect } from 'vitest';
3
+ import { extractSourceRefs } from '../../scripts/extract-source-refs.js';
4
+
5
+ function makeConcept(sources: any[], localizedSources?: any) {
6
+ return {
7
+ '@id': 'https://glossarist.org/ds1/concept/1.1',
8
+ 'gl:source': sources || [],
9
+ 'gl:localizedConcept': localizedSources || {},
10
+ };
11
+ }
12
+
13
+ function makeSource(source: string, refFrom: string, type = 'authoritative') {
14
+ return {
15
+ 'gl:origin': {
16
+ 'gl:ref': { 'gl:source': source },
17
+ 'gl:locality': { 'gl:type': type, 'gl:referenceFrom': refFrom },
18
+ },
19
+ };
20
+ }
21
+
22
+ describe('extractSourceRefs', () => {
23
+ it('extracts source from managed concept-level gl:source', () => {
24
+ const concept = makeConcept([makeSource('OIML V2-200:2012', '2.2')]);
25
+ const result = extractSourceRefs(concept, 'viml-2022');
26
+ expect(result).toEqual([{ source: 'OIML V2-200:2012', registerId: 'viml-2022' }]);
27
+ });
28
+
29
+ it('extracts source from localized concept gl:source', () => {
30
+ const concept = makeConcept([], {
31
+ eng: { 'gl:source': [makeSource('ISO/IEC 17000:2020', '3.1')] },
32
+ });
33
+ const result = extractSourceRefs(concept, 'viml-2022');
34
+ expect(result).toEqual([{ source: 'ISO/IEC 17000:2020', registerId: 'viml-2022' }]);
35
+ });
36
+
37
+ it('extracts from both managed and localized sources', () => {
38
+ const concept = makeConcept(
39
+ [makeSource('OIML V2-200:2012', '2.2')],
40
+ {
41
+ eng: { 'gl:source': [makeSource('ISO/IEC 17000:2020', '3.1')] },
42
+ fra: { 'gl:source': [makeSource('ISO/CEI 17000:2020', '3.1')] },
43
+ },
44
+ );
45
+ const result = extractSourceRefs(concept, 'viml-2022');
46
+ expect(result).toEqual([
47
+ { source: 'OIML V2-200:2012', registerId: 'viml-2022' },
48
+ { source: 'ISO/IEC 17000:2020', registerId: 'viml-2022' },
49
+ { source: 'ISO/CEI 17000:2020', registerId: 'viml-2022' },
50
+ ]);
51
+ });
52
+
53
+ it('deduplicates identical source strings', () => {
54
+ const concept = makeConcept(
55
+ [makeSource('VIM', '2.2')],
56
+ { eng: { 'gl:source': [makeSource('VIM', '2.3')] } },
57
+ );
58
+ const result = extractSourceRefs(concept, 'viml-2022');
59
+ expect(result).toEqual([{ source: 'VIM', registerId: 'viml-2022' }]);
60
+ });
61
+
62
+ it('returns empty array when no sources present', () => {
63
+ const concept = makeConcept();
64
+ const result = extractSourceRefs(concept, 'viml-2022');
65
+ expect(result).toEqual([]);
66
+ });
67
+
68
+ it('returns empty array for concept with no gl:source field', () => {
69
+ const concept = { '@id': 'https://glossarist.org/ds1/concept/1.1' };
70
+ const result = extractSourceRefs(concept, 'viml-2022');
71
+ expect(result).toEqual([]);
72
+ });
73
+
74
+ it('skips sources without gl:origin', () => {
75
+ const concept = makeConcept([{ 'gl:ref': { 'gl:source': 'VIM' } }]);
76
+ const result = extractSourceRefs(concept, 'ds1');
77
+ expect(result).toEqual([]);
78
+ });
79
+
80
+ it('skips origins without gl:ref', () => {
81
+ const concept = makeConcept([{ 'gl:origin': { 'gl:locality': {} } }]);
82
+ const result = extractSourceRefs(concept, 'ds1');
83
+ expect(result).toEqual([]);
84
+ });
85
+
86
+ it('skips refs without gl:source', () => {
87
+ const concept = makeConcept([{ 'gl:origin': { 'gl:ref': { 'gl:id': '2.2' } } }]);
88
+ const result = extractSourceRefs(concept, 'ds1');
89
+ expect(result).toEqual([]);
90
+ });
91
+
92
+ it('handles concept with multiple localized languages', () => {
93
+ const concept = makeConcept([], {
94
+ eng: { 'gl:source': [makeSource('OIML V 1:2022', '0.01')] },
95
+ fra: { 'gl:source': [makeSource('OIML V 1:2022', '0.01')] },
96
+ });
97
+ const result = extractSourceRefs(concept, 'viml-2022');
98
+ expect(result).toEqual([{ source: 'OIML V 1:2022', registerId: 'viml-2022' }]);
99
+ });
100
+
101
+ it('preserves registerId from the caller, not the concept data', () => {
102
+ const concept = makeConcept([makeSource('VIM', '2.2')]);
103
+ const result = extractSourceRefs(concept, 'my-dataset');
104
+ expect(result[0].registerId).toBe('my-dataset');
105
+ });
106
+
107
+ it('handles source strings with special characters', () => {
108
+ const concept = makeConcept([makeSource('OIML V 2:1993', '3.6')]);
109
+ const result = extractSourceRefs(concept, 'vim-1993');
110
+ expect(result).toEqual([{ source: 'OIML V 2:1993', registerId: 'vim-1993' }]);
111
+ });
112
+
113
+ it('handles source strings with whitespace variations', () => {
114
+ const concept = makeConcept([makeSource('ISO/ CEI 17000:2004', '3.1')]);
115
+ const result = extractSourceRefs(concept, 'viml-2013');
116
+ expect(result).toEqual([{ source: 'ISO/ CEI 17000:2004', registerId: 'viml-2013' }]);
117
+ });
118
+
119
+ it('extracts from multiple sources in a single concept', () => {
120
+ const concept = makeConcept([
121
+ makeSource('OIML V2-200:2012', '2.2'),
122
+ makeSource('ISO/IEC 17000:2020', '3.1'),
123
+ ]);
124
+ const result = extractSourceRefs(concept, 'viml-2022');
125
+ expect(result).toEqual([
126
+ { source: 'OIML V2-200:2012', registerId: 'viml-2022' },
127
+ { source: 'ISO/IEC 17000:2020', registerId: 'viml-2022' },
128
+ ]);
129
+ });
130
+
131
+ it('extracts URN-based source strings', () => {
132
+ const concept = makeConcept([makeSource('urn:oiml:pub:v:2:2012', '2.2')]);
133
+ const result = extractSourceRefs(concept, 'viml-2022');
134
+ expect(result).toEqual([{ source: 'urn:oiml:pub:v:2:2012', registerId: 'viml-2022' }]);
135
+ });
136
+ });
@@ -89,7 +89,7 @@ describe('AdapterFactory — lazy discovery', () => {
89
89
 
90
90
  expect(adapters.length).toBe(1);
91
91
  expect(adapters[0].manifest!.title).toBe('Full Dataset');
92
- // Should fetch both datasets.json and manifest.json
92
+ // Should fetch datasets.json and manifest.json
93
93
  expect(mockFetch).toHaveBeenCalledTimes(2);
94
94
  });
95
95
 
@@ -140,7 +140,7 @@ describe('AdapterFactory — lazy discovery', () => {
140
140
  return Promise.resolve({ ok: false, status: 404 } as Response);
141
141
  });
142
142
 
143
- // Discover with summary — no manifest fetch
143
+ // Discover with summary — only datasets.json fetched
144
144
  const adapters = await factory.discoverDatasets('/datasets.json');
145
145
  expect(adapters[0].manifest!.title).toBe('Summary Title');
146
146
  expect(mockFetch).toHaveBeenCalledTimes(1);