@glossarist/concept-browser 0.7.32 → 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.32",
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';
@@ -23,32 +24,6 @@ function slugify(text) {
23
24
 
24
25
  // --- Extractors (open/closed: add new extractors to EXTRACTORS array) ---
25
26
 
26
- function extractSourceRefs(concept, registerId) {
27
- const refs = new Set();
28
-
29
- // Managed concept-level sources
30
- for (const src of concept['gl:source'] || []) {
31
- const origin = src['gl:origin'];
32
- if (origin) {
33
- const ref = origin['gl:ref'];
34
- if (ref?.['gl:source']) refs.add(ref['gl:source']);
35
- }
36
- }
37
-
38
- // Localized concept-level sources
39
- for (const lc of Object.values(concept['gl:localizedConcept'] || {})) {
40
- for (const src of lc['gl:source'] || []) {
41
- const origin = src['gl:origin'];
42
- if (origin) {
43
- const ref = origin['gl:ref'];
44
- if (ref?.['gl:source']) refs.add(ref['gl:source']);
45
- }
46
- }
47
- }
48
-
49
- return [...refs].map(source => ({ source, registerId }));
50
- }
51
-
52
27
  function extractReferences(concept, registerId) {
53
28
  const edges = [];
54
29
  const sourceUri = concept['@id'];
@@ -323,34 +298,42 @@ for (const ds of datasets) {
323
298
  console.log();
324
299
  }
325
300
 
326
- // Build source-refs index: maps every source string to its dataset ID.
327
- // Uses manifest ref/refAliases as authoritative keys, augmented by
328
- // actual source strings found in concept data.
329
- const sourceRefMap = {};
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.
330
305
 
331
- // Seed from manifests (authoritative)
306
+ // Build a lookup of all known source strings from manifests
307
+ const knownSourceStrings = new Set();
332
308
  for (const [ds, manifest] of manifestCache) {
333
- if (manifest.ref) sourceRefMap[manifest.ref] = ds;
309
+ if (manifest.ref) knownSourceStrings.add(manifest.ref);
334
310
  for (const alias of manifest.refAliases ?? []) {
335
- sourceRefMap[alias] = ds;
311
+ knownSourceStrings.add(alias);
336
312
  }
337
- if (manifest.datasetUri) sourceRefMap[manifest.datasetUri] = ds;
313
+ if (manifest.datasetUri) knownSourceStrings.add(manifest.datasetUri);
338
314
  for (const alias of manifest.uriAliases ?? []) {
339
315
  const base = alias.endsWith('*') ? alias.slice(0, -1) : alias;
340
- sourceRefMap[base] = ds;
316
+ knownSourceStrings.add(base);
341
317
  }
342
318
  }
343
319
 
344
- // Augment with actual source strings from concepts
320
+ const auditUnmatched = new Map();
345
321
  for (const { source, registerId } of allSourceRefs) {
346
- if (!sourceRefMap[source]) {
347
- sourceRefMap[source] = registerId;
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);
348
327
  }
349
328
  }
350
329
 
351
- const sourceRefPath = join(DATA_DIR, 'source-refs.json');
352
- writeFileSync(sourceRefPath, JSON.stringify(sourceRefMap));
353
- console.log(`Written source-refs.json (${Object.keys(sourceRefMap).length} source references across ${datasets.length} datasets)\n`);
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
+ }
354
337
 
355
338
  // Build cross-reference index: for each dataset, which other datasets'
356
339
  // edges.json contains edges targeting that dataset's URIs.
@@ -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
+ });
@@ -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
+ });
@@ -56,8 +56,8 @@ describe('AdapterFactory — lazy discovery', () => {
56
56
  expect(adapters[0].manifest).not.toBeNull();
57
57
  expect(adapters[0].manifest!.title).toBe('Dataset 1');
58
58
  expect(adapters[0].manifest!.conceptCount).toBe(100);
59
- // Should NOT fetch manifest.json — only datasets.json + source-refs.json
60
- expect(mockFetch).toHaveBeenCalledTimes(2);
59
+ // Should NOT fetch manifest.json — only datasets.json
60
+ expect(mockFetch).toHaveBeenCalledTimes(1);
61
61
  expect(mockFetch).toHaveBeenCalledWith('/datasets.json');
62
62
  });
63
63
 
@@ -89,8 +89,8 @@ 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 datasets.json, manifest.json, and source-refs.json
93
- expect(mockFetch).toHaveBeenCalledTimes(3);
92
+ // Should fetch datasets.json and manifest.json
93
+ expect(mockFetch).toHaveBeenCalledTimes(2);
94
94
  });
95
95
 
96
96
  it('loads full manifest in loadDataset after summary discovery', async () => {
@@ -140,10 +140,10 @@ 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 (datasets.json + source-refs.json)
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
- expect(mockFetch).toHaveBeenCalledTimes(2);
146
+ expect(mockFetch).toHaveBeenCalledTimes(1);
147
147
 
148
148
  // Load full dataset — fetches full manifest + index
149
149
  const loaded = await factory.loadDataset('ds1');