sanity-plugin-taxonomy-manager 4.7.2 → 5.0.0

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.
Files changed (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +13 -108
  3. package/{lib → dist}/index.d.ts +66 -48
  4. package/dist/index.js +106 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +40 -46
  7. package/src/components/Children.tsx +4 -3
  8. package/src/components/Concepts.tsx +2 -2
  9. package/src/components/Hierarchy.test.tsx +92 -0
  10. package/src/components/Hierarchy.tsx +65 -30
  11. package/src/components/TopConcepts.tsx +2 -2
  12. package/src/components/TreeStructure.tsx +3 -3
  13. package/src/components/inputs/ArrayHierarchyInput.test.tsx +102 -0
  14. package/src/components/inputs/ArrayHierarchyInput.tsx +23 -24
  15. package/src/components/inputs/Identifier.tsx +1 -1
  16. package/src/components/inputs/InputHierarchy.test.tsx +38 -0
  17. package/src/components/inputs/InputHierarchy.tsx +20 -68
  18. package/src/components/inputs/ReferenceHierarchyInput.test.tsx +109 -0
  19. package/src/components/inputs/ReferenceHierarchyInput.tsx +24 -25
  20. package/src/components/interactions/ConceptDetailLink.tsx +0 -1
  21. package/src/components/interactions/ConceptEditAction.tsx +1 -1
  22. package/src/components/interactions/ConceptSelectLink.test.tsx +49 -0
  23. package/src/components/interactions/ConceptSelectLink.tsx +10 -6
  24. package/src/context.ts +34 -14
  25. package/src/core/__snapshots__/queries.test.ts.snap +386 -0
  26. package/src/core/__snapshots__/semanticRecommendations.test.ts.snap +10 -0
  27. package/src/core/createId.test.ts +35 -0
  28. package/src/core/filters.test.ts +64 -0
  29. package/src/core/filters.ts +68 -0
  30. package/src/core/ids.test.ts +68 -0
  31. package/src/core/ids.ts +61 -0
  32. package/src/core/mutations.test.ts +114 -0
  33. package/src/core/mutations.ts +105 -0
  34. package/src/core/ports.ts +59 -0
  35. package/src/core/queries.test.ts +50 -0
  36. package/src/{queries.ts → core/queries.ts} +7 -6
  37. package/src/core/semanticRecommendations.test.ts +160 -0
  38. package/src/core/semanticRecommendations.ts +127 -0
  39. package/src/core/tree/annotateRecommendations.test.ts +43 -0
  40. package/src/core/tree/annotateRecommendations.ts +30 -0
  41. package/src/core/tree/pruneConcepts.test.ts +77 -0
  42. package/src/core/tree/pruneConcepts.ts +72 -0
  43. package/src/core/validation.test.ts +42 -0
  44. package/src/core/validation.ts +42 -0
  45. package/src/helpers/baseIriField.ts +22 -0
  46. package/src/helpers/branchFilter.test.ts +48 -0
  47. package/src/helpers/branchFilter.ts +17 -26
  48. package/src/helpers/schemeFilter.test.ts +51 -0
  49. package/src/helpers/schemeFilter.ts +11 -23
  50. package/src/hooks/index.ts +1 -1
  51. package/src/hooks/useCreateConcept.tsx +33 -110
  52. package/src/hooks/useRemoveConcept.tsx +24 -36
  53. package/src/hooks/useSemanticRecommendations.tsx +75 -0
  54. package/src/index.test.ts +31 -0
  55. package/src/index.ts +2 -6
  56. package/src/seams/StudioDataAdapter.ts +81 -0
  57. package/src/seams/TaxonomyPortContext.tsx +33 -0
  58. package/src/skosConcept.tsx +23 -234
  59. package/src/skosConceptScheme.tsx +2 -4
  60. package/src/structure.tsx +60 -0
  61. package/src/test/FakeDataPort.test.ts +62 -0
  62. package/src/test/FakeDataPort.ts +69 -0
  63. package/src/test/inputHarness.tsx +71 -0
  64. package/src/test/renderWithUi.test.tsx +14 -0
  65. package/src/test/renderWithUi.tsx +24 -0
  66. package/src/test-setup.ts +35 -0
  67. package/src/types.tsx +30 -11
  68. package/src/views/ConceptUseView.tsx +4 -10
  69. package/lib/index.esm.d.mts +0 -492
  70. package/lib/index.esm.esm.js +0 -182
  71. package/lib/index.esm.esm.js.map +0 -1
  72. package/lib/index.esm.mjs +0 -182
  73. package/lib/index.esm.mjs.map +0 -1
  74. package/lib/index.js +0 -182
  75. package/lib/index.js.map +0 -1
  76. package/sanity.json +0 -8
  77. package/src/config.ts +0 -16
  78. package/src/helpers/baseIriField.tsx +0 -42
  79. package/src/hooks/useEmbeddingsRecs.tsx +0 -75
  80. package/src/skosConcept.module.css +0 -10
  81. package/src/structure.ts +0 -40
  82. package/v2-incompatible.js +0 -11
  83. /package/src/{helpers → core}/createId.ts +0 -0
@@ -0,0 +1,61 @@
1
+ import {
2
+ DocumentId,
3
+ getDraftId,
4
+ getVersionId,
5
+ getVersionNameFromId,
6
+ isPublishedId,
7
+ isVersionId,
8
+ type VersionId,
9
+ } from '@sanity/id-utils'
10
+
11
+ /** The `_strengthenOnPublish` template applied to weak skosConcept references. */
12
+ export const SKOS_CONCEPT_STRENGTHEN = {type: 'skosConcept', template: {id: 'skosConcept'}} as const
13
+
14
+ export type SchemeMutationTarget = {
15
+ isInRelease: boolean
16
+ releaseName: string | undefined
17
+ schemeId: string
18
+ }
19
+
20
+ /**
21
+ * Resolve the document id a scheme mutation should write to, from the displayed
22
+ * scheme id: the matching version id when the scheme is being edited inside a
23
+ * release, otherwise its draft id. Shared by concept create/remove.
24
+ */
25
+ export function deriveSchemeMutationTarget(displayedId: string): SchemeMutationTarget {
26
+ const docId = DocumentId(displayedId)
27
+ const isInRelease = isVersionId(docId)
28
+ const releaseName = isInRelease ? getVersionNameFromId(docId as VersionId) : undefined
29
+ const schemeId = isInRelease ? getVersionId(docId, releaseName as string) : getDraftId(docId)
30
+ return {isInRelease, releaseName, schemeId}
31
+ }
32
+
33
+ /**
34
+ * The id for a newly created concept, in the same release/draft context as its
35
+ * scheme. `uuid` is supplied by the caller (kept pure/deterministic here).
36
+ */
37
+ export function deriveNewConceptId(
38
+ uuid: string,
39
+ context: {isInRelease: boolean; releaseName: string | undefined},
40
+ ): string {
41
+ const docId = DocumentId(uuid)
42
+ return context.isInRelease
43
+ ? getVersionId(docId, context.releaseName as string)
44
+ : getDraftId(docId)
45
+ }
46
+
47
+ export type ReferenceStrength = {
48
+ _weak: boolean
49
+ _strengthenOnPublish?: typeof SKOS_CONCEPT_STRENGTHEN
50
+ }
51
+
52
+ /**
53
+ * Weak-reference flags for a reference to a concept that may not be published
54
+ * yet. When the referenced concept is a draft or version (not published), the
55
+ * reference must be weak and strengthened on publish so it survives until the
56
+ * target is published.
57
+ */
58
+ export function conceptReferenceStrength(referencedOriginalId: string): ReferenceStrength {
59
+ const published = isPublishedId(DocumentId(referencedOriginalId))
60
+ return published ? {_weak: false} : {_weak: true, _strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN}
61
+ }
@@ -0,0 +1,114 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import type {ConceptSchemeDocument} from '../types'
4
+
5
+ import {SKOS_CONCEPT_STRENGTHEN} from './ids'
6
+ import {planCreateConcept, planRemoveConcept} from './mutations'
7
+
8
+ const scheme = (id: string): ConceptSchemeDocument['displayed'] =>
9
+ ({_id: id, _type: 'skosConceptScheme', title: 'Test Scheme'}) as ConceptSchemeDocument['displayed']
10
+
11
+ describe('planCreateConcept', () => {
12
+ it('plans a top concept on a published scheme (draft target, no broader)', () => {
13
+ expect(
14
+ planCreateConcept({
15
+ scheme: scheme('scheme-1'),
16
+ conceptType: 'topConcept',
17
+ newConceptUuid: 'new-1',
18
+ conceptId: 'abc123',
19
+ schemeBaseIri: 'https://example.com/',
20
+ newConceptKey: 'ref-key',
21
+ broaderKey: 'broader-key',
22
+ }),
23
+ ).toEqual({
24
+ kind: 'create',
25
+ schemeId: 'drafts.scheme-1',
26
+ newConceptId: 'drafts.new-1',
27
+ createIfNotExists: {_id: 'drafts.scheme-1', _type: 'skosConceptScheme', title: 'Test Scheme'},
28
+ create: {
29
+ _id: 'drafts.new-1',
30
+ _type: 'skosConcept',
31
+ conceptId: 'abc123',
32
+ prefLabel: '',
33
+ baseIri: 'https://example.com/',
34
+ broader: [],
35
+ related: [],
36
+ },
37
+ appendField: 'topConcepts',
38
+ reference: {
39
+ _ref: 'new-1',
40
+ _type: 'reference',
41
+ _key: 'ref-key',
42
+ _strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN,
43
+ _weak: true,
44
+ },
45
+ })
46
+ })
47
+
48
+ it('plans a child concept on a draft scheme with a published broader (strong broader ref)', () => {
49
+ const plan = planCreateConcept({
50
+ scheme: scheme('drafts.scheme-1'),
51
+ conceptType: 'concept',
52
+ broaderConcept: {id: 'broader-1', _originalId: 'broader-1'},
53
+ newConceptUuid: 'new-2',
54
+ conceptId: 'def456',
55
+ schemeBaseIri: undefined,
56
+ newConceptKey: 'ref-key',
57
+ broaderKey: 'broader-key',
58
+ })
59
+ expect(plan.schemeId).toBe('drafts.scheme-1')
60
+ expect(plan.newConceptId).toBe('drafts.new-2')
61
+ expect(plan.appendField).toBe('concepts')
62
+ expect(plan.create.broader).toEqual([
63
+ {_key: 'broader-key', _ref: 'broader-1', _type: 'reference', _weak: false},
64
+ ])
65
+ })
66
+
67
+ it('plans a child concept in a release with a draft broader (weak broader ref)', () => {
68
+ const plan = planCreateConcept({
69
+ scheme: scheme('versions.rel1.scheme-1'),
70
+ conceptType: 'concept',
71
+ broaderConcept: {id: 'broader-2', _originalId: 'drafts.broader-2'},
72
+ newConceptUuid: 'new-3',
73
+ conceptId: 'ghi789',
74
+ schemeBaseIri: 'https://example.com/',
75
+ newConceptKey: 'ref-key',
76
+ broaderKey: 'broader-key',
77
+ })
78
+ expect(plan.schemeId).toBe('versions.rel1.scheme-1')
79
+ expect(plan.newConceptId).toBe('versions.rel1.new-3')
80
+ expect(plan.reference._ref).toBe('new-3')
81
+ expect(plan.create.broader).toEqual([
82
+ {
83
+ _key: 'broader-key',
84
+ _ref: 'broader-2',
85
+ _type: 'reference',
86
+ _weak: true,
87
+ _strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN,
88
+ },
89
+ ])
90
+ })
91
+ })
92
+
93
+ describe('planRemoveConcept', () => {
94
+ it('plans removal of a top concept from a published scheme (draft target)', () => {
95
+ expect(
96
+ planRemoveConcept({scheme: scheme('scheme-1'), conceptRef: 'concept-9', conceptType: 'topConcept'}),
97
+ ).toEqual({
98
+ kind: 'remove',
99
+ schemeId: 'drafts.scheme-1',
100
+ createIfNotExists: {_id: 'drafts.scheme-1', _type: 'skosConceptScheme', title: 'Test Scheme'},
101
+ unsetPaths: ['topConcepts[_ref=="concept-9"]'],
102
+ })
103
+ })
104
+
105
+ it('plans removal of a concept from a scheme in a release (version target)', () => {
106
+ const plan = planRemoveConcept({
107
+ scheme: scheme('versions.rel1.scheme-1'),
108
+ conceptRef: 'concept-9',
109
+ conceptType: 'concept',
110
+ })
111
+ expect(plan.schemeId).toBe('versions.rel1.scheme-1')
112
+ expect(plan.unsetPaths).toEqual(['concepts[_ref=="concept-9"]'])
113
+ })
114
+ })
@@ -0,0 +1,105 @@
1
+ import {DocumentId, getPublishedId} from '@sanity/id-utils'
2
+
3
+ import type {ConceptSchemeDocument, SkosConceptDocument, SkosConceptReference} from '../types'
4
+
5
+ import {
6
+ conceptReferenceStrength,
7
+ deriveNewConceptId,
8
+ deriveSchemeMutationTarget,
9
+ SKOS_CONCEPT_STRENGTHEN,
10
+ } from './ids'
11
+
12
+ type SchemeDisplayed = ConceptSchemeDocument['displayed']
13
+
14
+ /**
15
+ * "Describe, don't execute": the create/remove planners below compute a pure
16
+ * description of the transaction a concept mutation should run. The hooks stay
17
+ * thin — they generate the random ids/keys, pass them in, and replay the plan
18
+ * onto client.transaction(). This makes the release/version-aware mutation logic
19
+ * exhaustively unit-testable without a Studio.
20
+ */
21
+
22
+ export type CreateConceptPlan = {
23
+ kind: 'create'
24
+ schemeId: string
25
+ newConceptId: string
26
+ createIfNotExists: SchemeDisplayed & {_id: string}
27
+ create: SkosConceptDocument
28
+ appendField: 'topConcepts' | 'concepts'
29
+ reference: SkosConceptReference
30
+ }
31
+
32
+ export function planCreateConcept(input: {
33
+ scheme: SchemeDisplayed
34
+ conceptType: 'topConcept' | 'concept'
35
+ broaderConcept?: {id: string; _originalId: string}
36
+ newConceptUuid: string
37
+ conceptId: string
38
+ schemeBaseIri: string | undefined
39
+ newConceptKey: string
40
+ broaderKey: string
41
+ }): CreateConceptPlan {
42
+ const target = deriveSchemeMutationTarget(input.scheme._id)
43
+ const newConceptId = deriveNewConceptId(input.newConceptUuid, target)
44
+
45
+ const create: SkosConceptDocument = {
46
+ _id: newConceptId,
47
+ _type: 'skosConcept',
48
+ conceptId: input.conceptId,
49
+ prefLabel: '',
50
+ baseIri: input.schemeBaseIri,
51
+ broader: [],
52
+ related: [],
53
+ }
54
+
55
+ if (input.broaderConcept) {
56
+ create.broader = [
57
+ {
58
+ _key: input.broaderKey,
59
+ _ref: getPublishedId(DocumentId(input.broaderConcept.id)),
60
+ _type: 'reference',
61
+ ...conceptReferenceStrength(input.broaderConcept._originalId),
62
+ },
63
+ ]
64
+ }
65
+
66
+ const reference: SkosConceptReference = {
67
+ _ref: getPublishedId(DocumentId(newConceptId)),
68
+ _type: 'reference',
69
+ _key: input.newConceptKey,
70
+ _strengthenOnPublish: SKOS_CONCEPT_STRENGTHEN,
71
+ _weak: true,
72
+ }
73
+
74
+ return {
75
+ kind: 'create',
76
+ schemeId: target.schemeId,
77
+ newConceptId,
78
+ createIfNotExists: {...input.scheme, _id: target.schemeId},
79
+ create,
80
+ appendField: input.conceptType === 'topConcept' ? 'topConcepts' : 'concepts',
81
+ reference,
82
+ }
83
+ }
84
+
85
+ export type RemoveConceptPlan = {
86
+ kind: 'remove'
87
+ schemeId: string
88
+ createIfNotExists: SchemeDisplayed & {_id: string}
89
+ unsetPaths: string[]
90
+ }
91
+
92
+ export function planRemoveConcept(input: {
93
+ scheme: SchemeDisplayed
94
+ conceptRef: string
95
+ conceptType: string
96
+ }): RemoveConceptPlan {
97
+ const field = input.conceptType === 'topConcept' ? 'topConcepts' : 'concepts'
98
+ const {schemeId} = deriveSchemeMutationTarget(input.scheme._id)
99
+ return {
100
+ kind: 'remove',
101
+ schemeId,
102
+ createIfNotExists: {...input.scheme, _id: schemeId},
103
+ unsetPaths: [`${field}[_ref=="${input.conceptRef}"]`],
104
+ }
105
+ }
@@ -0,0 +1,59 @@
1
+ import type {CreateConceptPlan, RemoveConceptPlan} from './mutations'
2
+ import type {ConceptRecommendation, DocumentConcepts, SemanticSearchConfig} from '../types'
3
+
4
+ /**
5
+ * #### Taxonomy Data Port
6
+ * The single internal seam for everything that touches Sanity Studio or the
7
+ * network: the live concept-tree query, concept-mutation transactions, and
8
+ * semantic term recommendations. Components and mutation hooks talk to this
9
+ * interface instead of `useListeningQuery`/`useClient` directly, so the render
10
+ * tree can be exercised against an in-memory fake (`test/FakeDataPort`) and the
11
+ * implementation can be swapped later (e.g. the App SDK) without touching
12
+ * components.
13
+ *
14
+ * Members are hooks (`use*`): the tree seam wraps Studio's
15
+ * `documentStore.listenQuery` and the mutation/embeddings seams read the Studio
16
+ * client from React context, so they obey the rules of hooks. They are reached
17
+ * via `useTaxonomyDataPort()` — default is the real `StudioDataAdapter`, and
18
+ * tests inject a fake through `TaxonomyPortProvider`.
19
+ */
20
+ export interface TaxonomyDataPort {
21
+ /** Live concept tree for a scheme document (`trunk`) or a branch within it (`input`). */
22
+ useWatchTree(params: ConceptTreeParams): WatchResult<DocumentConcepts>
23
+ /** Replay a pure create/remove plan (`core/mutations`) onto a Studio transaction. */
24
+ useApplyConceptPlan(): (plan: ConceptPlan) => Promise<void>
25
+ /** Semantic term recommendations for the input components (GROQ `text::semanticSimilarity()`). */
26
+ useSemanticRecommendations(config?: SemanticSearchConfig): SemanticRecommendationsResult
27
+ }
28
+
29
+ /**
30
+ * Perspective intent, mirroring `ReleaseContext` (`selectedPerspectiveName` from
31
+ * `usePerspective()`): `undefined` ⇒ drafts, otherwise the named perspective.
32
+ */
33
+ export type TaxonomyPerspective = string | undefined
34
+
35
+ /**
36
+ * Which tree to watch. `trunk` is the full scheme hierarchy (the structure Tree
37
+ * View); `input` is a (possibly branch-scoped) tree for the field input dialog.
38
+ */
39
+ export type ConceptTreeParams =
40
+ | {mode: 'trunk'; documentId: string; perspective: TaxonomyPerspective}
41
+ | {mode: 'input'; documentId: string; branchId: string | null; perspective: TaxonomyPerspective}
42
+
43
+ /** Result shape of a live query: mirrors `useListeningQuery`, with `error` normalized to `Error | null`. */
44
+ export interface WatchResult<T> {
45
+ data: T | null
46
+ loading: boolean
47
+ error: Error | null
48
+ }
49
+
50
+ /** A create or remove transaction plan, discriminated by `kind` (see `core/mutations`). */
51
+ export type ConceptPlan = CreateConceptPlan | RemoveConceptPlan
52
+
53
+ /** Return shape of the semantic-recommendations seam. */
54
+ export interface SemanticRecommendationsResult {
55
+ conceptRecs: ConceptRecommendation[]
56
+ recsError: string | null
57
+ /** Run the search, scoping recommendations to the given scheme; a no-op without a `schemeId`. */
58
+ triggerSearch: (schemeId?: string) => void
59
+ }
@@ -0,0 +1,50 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {inputBuilder, trunkBuilder} from './queries'
4
+
5
+ // Characterization tests: pin the exact GROQ the builders emit today so the
6
+ // Stage 3 extraction into core/ (and making the depth cap configurable) cannot
7
+ // silently change behavior. The full strings are snapshotted; the structural
8
+ // assertions document the load-bearing parts.
9
+
10
+ describe('trunkBuilder', () => {
11
+ const query = trunkBuilder()
12
+
13
+ it('matches the characterized GROQ output', () => {
14
+ expect(query).toMatchSnapshot()
15
+ })
16
+
17
+ it('selects the scheme by $id and projects topConcepts + concepts, ordered by prefLabel', () => {
18
+ expect(query).toContain('*[_id == $id][0]')
19
+ expect(query).toContain('"topConcepts": topConcepts[]->|order(prefLabel)')
20
+ expect(query).toContain('"concepts": concepts[]->|order(prefLabel)')
21
+ })
22
+
23
+ it('computes isOrphan via array::intersects of broader refs against the scheme refs', () => {
24
+ expect(query).toContain('"isOrphan":')
25
+ expect(query).toContain('array::intersects')
26
+ })
27
+
28
+ it('recurses childConcepts to a depth cap of 6 levels', () => {
29
+ expect(query).toContain('"level": 6')
30
+ expect(query).not.toContain('"level": 7')
31
+ })
32
+ })
33
+
34
+ describe('inputBuilder', () => {
35
+ const query = inputBuilder()
36
+
37
+ it('matches the characterized GROQ output', () => {
38
+ expect(query).toMatchSnapshot()
39
+ })
40
+
41
+ it('branches on the presence of $branchId via select()', () => {
42
+ expect(query).toContain('select($branchId != null =>')
43
+ expect(query).toContain('$branchId in @->broader[]->.conceptId')
44
+ })
45
+
46
+ it('recurses childConcepts to a depth cap of 6 levels', () => {
47
+ expect(query).toContain('"level": 6')
48
+ expect(query).not.toContain('"level": 7')
49
+ })
50
+ })
@@ -1,13 +1,14 @@
1
1
  /**
2
- * ### Tree Builder
3
- * Recursive function to build out successive branches of the hierarchy
4
- * up to five levels deep.
2
+ * Maximum hierarchy depth the builders recurse to. This is the single knob for
3
+ * the depth cap raising it later (if performance permits and use cases
4
+ * warrant) is a one-line change. Kept at its long-standing value of 6.
5
5
  */
6
+ const MAX_TREE_DEPTH = 6
6
7
 
7
8
  /**
8
9
  * #### Branch Builder
9
- * Recursive function to build out successive branches of the hierarchy
10
- * up to five levels deep.
10
+ * Recursive builder for successive branches of the concept hierarchy, down to
11
+ * `MAX_TREE_DEPTH` levels deep.
11
12
  */
12
13
  const branchBuilder = (level = 1): string | void => {
13
14
  let reference = '^.^.concepts[]._ref'
@@ -16,7 +17,7 @@ const branchBuilder = (level = 1): string | void => {
16
17
  reference = `^.${reference}`
17
18
  i++
18
19
  }
19
- if (level > 6) {
20
+ if (level > MAX_TREE_DEPTH) {
20
21
  return ''
21
22
  }
22
23
  return `"childConcepts": *[_id in ${reference} && ^._id in broader[]._ref]|order(prefLabel)
@@ -0,0 +1,160 @@
1
+ import {describe, expect, it} from 'vitest'
2
+
3
+ import {
4
+ assembleQueryText,
5
+ recommendationsErrorMessage,
6
+ recommendationsQuery,
7
+ recommendedConceptIds,
8
+ toConceptRecommendations,
9
+ } from './semanticRecommendations'
10
+
11
+ describe('recommendationsQuery', () => {
12
+ const query = recommendationsQuery()
13
+
14
+ it('matches the characterized GROQ output', () => {
15
+ expect(query).toMatchSnapshot()
16
+ })
17
+
18
+ it('scores skosConcepts by semanticSimilarity and returns the top $maxResults', () => {
19
+ expect(query).toContain('_type == "skosConcept"')
20
+ expect(query).toContain('score(text::semanticSimilarity($searchQuery))')
21
+ expect(query).toContain('order(_score desc)')
22
+ expect(query).toContain('[0...$maxResults]')
23
+ expect(query).toContain('"conceptId": _id')
24
+ expect(query).toContain('"score": _score')
25
+ })
26
+
27
+ it("scopes candidates to the field's scheme via $schemeId membership", () => {
28
+ expect(query).toContain('_type == "skosConceptScheme" && schemeId == $schemeId')
29
+ expect(query).toContain('topConcepts[]._ref')
30
+ expect(query).toContain('concepts[]._ref')
31
+ expect(query).toContain('_id in')
32
+ })
33
+
34
+ it('uses no part of the deprecated Embeddings Index API', () => {
35
+ expect(query).not.toContain('text::embedding')
36
+ expect(query).not.toContain('embeddings-index')
37
+ })
38
+ })
39
+
40
+ describe('assembleQueryText', () => {
41
+ it('joins the non-empty field values with a space, in order', () => {
42
+ const text = assembleQueryText([
43
+ {name: 'title', value: 'Summer'},
44
+ {name: 'description', value: 'reading list'},
45
+ ])
46
+ expect(text).toBe('Summer reading list')
47
+ })
48
+
49
+ it('throws naming the single empty field', () => {
50
+ expect(() =>
51
+ assembleQueryText([
52
+ {name: 'title', value: 'Summer'},
53
+ {name: 'description', value: ''},
54
+ ])
55
+ ).toThrowError('Please fill out the description field to enable match scores.')
56
+ })
57
+
58
+ it('throws listing every empty field when more than one is empty', () => {
59
+ expect(() =>
60
+ assembleQueryText([
61
+ {name: 'title', value: ''},
62
+ {name: 'description', value: ' '},
63
+ ])
64
+ ).toThrowError(
65
+ 'The following fields must be filled out to enable match scores: title, description'
66
+ )
67
+ })
68
+
69
+ it('treats whitespace-only and non-string values as empty', () => {
70
+ expect(() =>
71
+ assembleQueryText([
72
+ {name: 'title', value: ' '},
73
+ {name: 'count', value: 42},
74
+ ])
75
+ ).toThrowError(
76
+ 'The following fields must be filled out to enable match scores: title, count'
77
+ )
78
+ })
79
+ })
80
+
81
+ describe('toConceptRecommendations', () => {
82
+ it('maps rows to {conceptId, score}', () => {
83
+ const recs = toConceptRecommendations([
84
+ {conceptId: 'concept-a', score: 0.9},
85
+ {conceptId: 'concept-b', score: 0.4},
86
+ ])
87
+ expect(recs).toEqual([
88
+ {conceptId: 'concept-a', score: 0.9},
89
+ {conceptId: 'concept-b', score: 0.4},
90
+ ])
91
+ })
92
+
93
+ it('normalizes draft and version ids to their published form', () => {
94
+ const recs = toConceptRecommendations([
95
+ {conceptId: 'drafts.concept-a', score: 0.9},
96
+ {conceptId: 'versions.rABC.concept-b', score: 0.4},
97
+ ])
98
+ expect(recs).toEqual([
99
+ {conceptId: 'concept-a', score: 0.9},
100
+ {conceptId: 'concept-b', score: 0.4},
101
+ ])
102
+ })
103
+
104
+ it('drops rows missing a string id or numeric score', () => {
105
+ const recs = toConceptRecommendations([
106
+ {conceptId: 'concept-a', score: 0.9},
107
+ {conceptId: '', score: 0.5},
108
+ {conceptId: 'concept-c', score: null as unknown as number},
109
+ null as unknown as {conceptId: string; score: number},
110
+ ])
111
+ expect(recs).toEqual([{conceptId: 'concept-a', score: 0.9}])
112
+ })
113
+
114
+ it('returns an empty array for null or undefined input', () => {
115
+ expect(toConceptRecommendations(null)).toEqual([])
116
+ expect(toConceptRecommendations(undefined)).toEqual([])
117
+ })
118
+ })
119
+
120
+ describe('recommendedConceptIds', () => {
121
+ it('collects the recommended concept ids as a set', () => {
122
+ const ids = recommendedConceptIds([
123
+ {conceptId: 'a', score: 0.9},
124
+ {conceptId: 'b', score: 0.4},
125
+ ])
126
+ expect(ids).toEqual(new Set(['a', 'b']))
127
+ })
128
+
129
+ it('de-duplicates repeated concept ids', () => {
130
+ const ids = recommendedConceptIds([
131
+ {conceptId: 'a', score: 0.9},
132
+ {conceptId: 'a', score: 0.3},
133
+ {conceptId: 'b', score: 0.5},
134
+ ])
135
+ expect(ids).toEqual(new Set(['a', 'b']))
136
+ })
137
+
138
+ it('returns an empty set for no recommendations', () => {
139
+ expect(recommendedConceptIds([]).size).toBe(0)
140
+ })
141
+ })
142
+
143
+ describe('recommendationsErrorMessage', () => {
144
+ it('flags an embeddings-related error as the dataset not being enabled', () => {
145
+ const message = recommendationsErrorMessage(
146
+ new Error('text::semanticSimilarity requires embeddings to be enabled')
147
+ )
148
+ expect(message).toBe("Semantic recommendations aren't enabled for this dataset.")
149
+ })
150
+
151
+ it('falls back to a generic message for any other failure', () => {
152
+ expect(recommendationsErrorMessage(new Error('Network request failed'))).toBe(
153
+ 'Unable to load semantic recommendations.'
154
+ )
155
+ expect(recommendationsErrorMessage('socket hang up')).toBe(
156
+ 'Unable to load semantic recommendations.'
157
+ )
158
+ expect(recommendationsErrorMessage(undefined)).toBe('Unable to load semantic recommendations.')
159
+ })
160
+ })