@eeacms/volto-cca-policy 1.0.23 → 1.0.24

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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [1.0.24](https://github.com/eea/volto-cca-policy/compare/1.0.23...1.0.24) - 17 September 2026
8
+
9
+ #### :bug: Bug Fixes
10
+
11
+ - fix: unwrap accidental code blocks and add resilience against varied assistant formats [Tiberiu Ichim - [`b245de7`](https://github.com/eea/volto-cca-policy/commit/b245de770a2e6d9a5741d240dc6649d2df7674bc)]
12
+ - fix: hide document card when Elasticsearch lookup returns empty or not found [Tiberiu Ichim - [`6acb0ac`](https://github.com/eea/volto-cca-policy/commit/6acb0acf1ad558cf259a61ed25c6860a08edc1ee)]
13
+ - fix: make catalogue cards and date formatting resilient against malformed data [Tiberiu Ichim - [`858ec4e`](https://github.com/eea/volto-cca-policy/commit/858ec4e2b7c8857ec43f080b9f5368e50bad40d4)]
14
+
7
15
  ### [1.0.23](https://github.com/eea/volto-cca-policy/compare/1.0.22...1.0.23) - 16 September 2026
8
16
 
9
17
  #### :rocket: New Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "@eeacms/volto-cca-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -54,6 +54,23 @@ const publicationDateFormatter = new Intl.DateTimeFormat('en-GB', {
54
54
  timeZone: 'UTC',
55
55
  });
56
56
 
57
+ const formatPublicationDate = (value) => {
58
+ if (!value) return '';
59
+ const raw =
60
+ typeof value === 'object' && value !== null && 'raw' in value
61
+ ? value.raw
62
+ : value;
63
+ const val = Array.isArray(raw) ? raw[0] : raw;
64
+ if (!val || typeof val === 'object') return '';
65
+ try {
66
+ const d = new Date(val);
67
+ if (Number.isNaN(d.getTime())) return '';
68
+ return publicationDateFormatter.format(d);
69
+ } catch {
70
+ return '';
71
+ }
72
+ };
73
+
57
74
  const TagGroup = ({ typeLabel, values, type }) => {
58
75
  const visible = values.slice(0, 3);
59
76
  const hidden = values.slice(3);
@@ -117,7 +134,7 @@ const CycleElements = ({ intl, values }) => {
117
134
  };
118
135
 
119
136
  const NavigatorCatalogueCardItem = (props) => {
120
- const { result } = props;
137
+ const { result = {} } = props;
121
138
  const intl = useIntl();
122
139
  const sectors = rawValueAsArray(result.cca_adaptation_sectors);
123
140
  const hazards = rawValueAsArray(result.cca_climate_impacts);
@@ -131,13 +148,14 @@ const NavigatorCatalogueCardItem = (props) => {
131
148
  const adaptationSupportCycleSteps = rawValueAsArray(
132
149
  result.adaptation_support_cycle_step,
133
150
  )
134
- .map((value) => value?.title?.split(':')[0])
151
+ .map((value) => {
152
+ const title = value?.title || value;
153
+ return typeof title === 'string' ? title.split(':')[0] : title;
154
+ })
135
155
  .filter(Boolean);
136
156
  const publicationDate =
137
157
  result.publication_date?.raw || result.publication_date;
138
- const formattedPublicationDate = publicationDate
139
- ? publicationDateFormatter.format(new Date(publicationDate))
140
- : '';
158
+ const formattedPublicationDate = formatPublicationDate(publicationDate);
141
159
  const sectorLabel = intl.formatMessage(messages.sector);
142
160
  const hazardLabel = intl.formatMessage(messages.hazard);
143
161
  const compareTool = {
@@ -175,6 +175,50 @@ describe('NavigatorCatalogueCardItem', () => {
175
175
  expect(screen.queryByText('License:')).not.toBeInTheDocument();
176
176
  });
177
177
 
178
+ it('safely handles invalid, array, or malformed publication dates without throwing RangeError', () => {
179
+ // Malformed/invalid date string that would throw RangeError: Invalid time value
180
+ const { rerender } = renderCard({
181
+ title: 'Invalid date tool',
182
+ publication_date: 'N/A',
183
+ });
184
+ expect(screen.getByText('Invalid date tool')).toBeInTheDocument();
185
+ expect(screen.queryByText('N/A')).not.toBeInTheDocument();
186
+
187
+ // Array publication date
188
+ rerender(
189
+ <IntlProvider locale="en">
190
+ <NavigatorCatalogueCardItem
191
+ result={{
192
+ title: 'Array date tool',
193
+ publication_date: { raw: ['2026-07-24'] },
194
+ }}
195
+ />
196
+ </IntlProvider>,
197
+ );
198
+ expect(screen.getByText('24 Jul 26')).toBeInTheDocument();
199
+
200
+ // Malformed object publication date
201
+ rerender(
202
+ <IntlProvider locale="en">
203
+ <NavigatorCatalogueCardItem
204
+ result={{
205
+ title: 'Object date tool',
206
+ publication_date: { raw: { invalid: true } },
207
+ }}
208
+ />
209
+ </IntlProvider>,
210
+ );
211
+ expect(screen.getByText('Object date tool')).toBeInTheDocument();
212
+ });
213
+
214
+ it('safely handles non-string adaptationSupportCycleSteps without throwing', () => {
215
+ renderCard({
216
+ title: 'Numeric cycle tool',
217
+ adaptation_support_cycle_step: { raw: [123, null, { title: 456 }] },
218
+ });
219
+ expect(screen.getByText('Numeric cycle tool')).toBeInTheDocument();
220
+ });
221
+
178
222
  describe('thumbnail rendering and fallback', () => {
179
223
  it('renders thumbnail image and smoothly transitions from placeholder on load', () => {
180
224
  const { container } = renderCard({
@@ -1,6 +1,4 @@
1
1
  import React, { useContext, memo } from 'react';
2
- import { Icon } from 'semantic-ui-react';
3
- import ExternalLink from '@eeacms/search/components/Result/ExternalLink';
4
2
 
5
3
  import NavigatorCatalogueCardItem from '@eeacms/volto-cca-policy/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem';
6
4
  // The `ChatMessageContext` seam only exists in @eeacms/volto-eea-chatbot from
@@ -54,137 +52,58 @@ export function matchesDocumentTitle(docTitle, searchTitle) {
54
52
  return false;
55
53
  }
56
54
 
57
- function formatDate(value) {
58
- if (!value) return null;
59
- const d = new Date(value);
60
- if (Number.isNaN(d.getTime())) return null;
61
- return d.toLocaleDateString('en-GB', {
62
- day: '2-digit',
63
- month: 'short',
64
- year: '2-digit',
65
- });
55
+ function sourcePropsEqual(prev, next) {
56
+ return prev.source?.link === next.source?.link;
66
57
  }
67
58
 
68
- // `InlineDocCard` rebuilds the `source` object on every render (the answer is
69
- // re-rendered on each streaming tick), so a plain `===` prop comparison would
70
- // never memoize. Compare the actual fields instead.
71
- function sourceEqual(prevSource, nextSource) {
72
- return (
73
- prevSource?.semantic_identifier === nextSource?.semantic_identifier &&
74
- prevSource?.blurb === nextSource?.blurb &&
75
- prevSource?.updated_at === nextSource?.updated_at &&
76
- prevSource?.source_type === nextSource?.source_type &&
77
- prevSource?.link === nextSource?.link
78
- );
79
- }
59
+ export class DocCardErrorBoundary extends React.Component {
60
+ constructor(props) {
61
+ super(props);
62
+ this.state = { hasError: false };
63
+ }
80
64
 
81
- function sourcePropsEqual(prev, next) {
82
- return prev.index === next.index && sourceEqual(prev.source, next.source);
65
+ static getDerivedStateFromError() {
66
+ return { hasError: true };
67
+ }
68
+
69
+ componentDidCatch(error, info) {
70
+ // eslint-disable-next-line no-console
71
+ console.warn('DocCard failed to render rich catalogue card:', error, info);
72
+ }
73
+
74
+ render() {
75
+ if (this.state.hasError) {
76
+ return this.props.fallback ?? null;
77
+ }
78
+ return this.props.children;
79
+ }
83
80
  }
84
81
 
85
82
  /**
86
- * Document card styled to match the Navigator catalogue cards
87
- * (volto-cca-policy NavigatorCatalogueCardItem), used when full searchlib
88
- * metadata is not yet loaded or for external/unindexed sources.
83
+ * Document card rendered once the globalsearch ES lookup (by document URL)
84
+ * resolves as the full Navigator catalogue card.
85
+ *
86
+ * If the document is not found in Elasticsearch (or lookup fails), no card
87
+ * is displayed.
89
88
  */
90
- const DocumentCard = memo(function DocumentCard({ source, index }) {
91
- if (!source || typeof source !== 'object' || !source.semantic_identifier) {
89
+ const EnhancedDocCard = memo(function EnhancedDocCard({ source }) {
90
+ const { result } = useCatalogueDoc(source?.link);
91
+
92
+ if (!result) {
92
93
  return null;
93
94
  }
94
- const {
95
- semantic_identifier: rawTitle,
96
- blurb,
97
- updated_at,
98
- source_type,
99
- link,
100
- } = source;
101
- const isWeb = source_type === 'web';
102
- const title = cleanDocumentTitle(rawTitle);
103
- const formattedDate = formatDate(updated_at);
104
- const typeLabel = isWeb ? 'Web' : 'Document';
105
95
 
106
96
  return (
107
- <div className="catalogue-chat-navigator-card">
108
- <div className="navigator-catalogue-item">
109
- <div className="navigator-tool-icon large" aria-hidden="true">
110
- <Icon className="ri-file-line" />
111
- </div>
112
-
113
- <div className="catalogue-item-main">
114
- <div className="catalogue-item-top">
115
- <div className="navigator-tool-provider">
116
- {typeof index === 'number' && (
117
- <span className="chat-citation">{index} </span>
118
- )}
119
- {typeLabel}
120
- </div>
121
- {formattedDate && (
122
- <span className="catalogue-date">{formattedDate}</span>
123
- )}
124
- </div>
125
-
126
- <div className="catalogue-item-heading">
127
- <h4>
128
- {link ? (
129
- <ExternalLink href={link} title={title}>
130
- {title}
131
- </ExternalLink>
132
- ) : (
133
- <span title={title}>{title}</span>
134
- )}
135
- </h4>
136
- </div>
137
-
138
- {blurb && <p className="catalogue-description">{blurb}</p>}
139
-
140
- <div className="catalogue-item-footer">
141
- <div className="catalogue-meta license-type">
142
- <span className="catalogue-type">Type: {typeLabel}</span>
143
- </div>
144
-
145
- <div className="catalogue-actions">
146
- {link && (
147
- <ExternalLink
148
- href={link}
149
- className="ui button primary icon"
150
- labelPosition="left"
151
- >
152
- View
153
- <Icon className="ri-arrow-right-line" />
154
- </ExternalLink>
155
- )}
156
- </div>
157
- </div>
97
+ <DocCardErrorBoundary fallback={null}>
98
+ <div className="catalogue-chat-card-inline">
99
+ <div className="catalogue-chat-navigator-card">
100
+ <NavigatorCatalogueCardItem result={result} />
158
101
  </div>
159
102
  </div>
160
- </div>
103
+ </DocCardErrorBoundary>
161
104
  );
162
105
  }, sourcePropsEqual);
163
106
 
164
- /**
165
- * Document card that upgrades itself: while it has only the Onyx-provided
166
- * fields it renders the basic `DocumentCard`; once the globalsearch ES
167
- * lookup (by document URL) resolves it re-renders as the full Navigator
168
- * catalogue card.
169
- *
170
- * Memoized: the answer markdown is re-rendered on every streaming tick, but
171
- * the (already-shown, potentially heavy) card should only re-render when its
172
- * own data changes, not on every tick. Its own basic→full upgrade is driven
173
- * by internal state, which still re-renders it exactly once.
174
- */
175
- const EnhancedDocCard = memo(function EnhancedDocCard({ source, index }) {
176
- const { result } = useCatalogueDoc(source?.link);
177
-
178
- if (result) {
179
- return (
180
- <div className="catalogue-chat-navigator-card">
181
- <NavigatorCatalogueCardItem result={result} />
182
- </div>
183
- );
184
- }
185
- return <DocumentCard source={source} index={index} />;
186
- }, sourcePropsEqual);
187
-
188
107
  /**
189
108
  * Inline document card, rendered in the message text where the assistant
190
109
  * emitted a `![[doc: Title]]` marker (see docCards.js). Tries to match the
@@ -204,11 +123,7 @@ export function InlineDocCard({ title, documents = [] }) {
204
123
  source_type: doc?.source_type,
205
124
  link: doc?.link,
206
125
  };
207
- return (
208
- <div className="catalogue-chat-card-inline">
209
- <EnhancedDocCard source={source} />
210
- </div>
211
- );
126
+ return <EnhancedDocCard source={source} />;
212
127
  }
213
128
 
214
129
  /**
@@ -1,10 +1,16 @@
1
1
  import React from 'react';
2
2
  import '@testing-library/jest-dom';
3
- import { cleanup, render, screen } from '@testing-library/react';
3
+ import { render, screen } from '@testing-library/react';
4
4
 
5
5
  import { ChatMessageContext } from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
6
6
  import { useCatalogueDoc } from './useCatalogueDoc';
7
- import { CcaDocCard, InlineDocCard } from './DocumentCard';
7
+ import {
8
+ CcaDocCard,
9
+ DocCardErrorBoundary,
10
+ InlineDocCard,
11
+ cleanDocumentTitle,
12
+ matchesDocumentTitle,
13
+ } from './DocumentCard';
8
14
 
9
15
  jest.mock('@eeacms/volto-eea-chatbot/ChatBlock/chat', () => ({
10
16
  ChatMessageContext:
@@ -12,14 +18,20 @@ jest.mock('@eeacms/volto-eea-chatbot/ChatBlock/chat', () => ({
12
18
  require('react').createContext(null),
13
19
  }));
14
20
 
21
+ let mockNavigatorShouldThrow = false;
22
+
15
23
  jest.mock(
16
24
  '@eeacms/volto-cca-policy/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem',
17
- () =>
18
- ({ result }) => <div data-testid="navigator-card" />,
25
+ () => (props) => {
26
+ if (mockNavigatorShouldThrow) {
27
+ throw new RangeError('Invalid time value');
28
+ }
29
+ return <div data-testid="navigator-card" />;
30
+ },
19
31
  );
20
32
 
21
33
  jest.mock('./useCatalogueDoc', () => ({
22
- useCatalogueDoc: jest.fn(() => ({ result: null, loading: true })),
34
+ useCatalogueDoc: jest.fn(() => ({ result: null, loading: false })),
23
35
  }));
24
36
 
25
37
  const doc = {
@@ -30,66 +42,75 @@ const doc = {
30
42
  link: 'https://example.com/france-nas',
31
43
  };
32
44
 
33
- const webDoc = {
34
- semantic_identifier: 'Some Web Source',
35
- blurb: 'A web page.',
36
- updated_at: '2023-05-01T00:00:00Z',
37
- source_type: 'web',
38
- link: 'https://example.com/web',
39
- };
45
+ describe('cleanDocumentTitle', () => {
46
+ it('strips pipe-separated suffixes', () => {
47
+ expect(
48
+ cleanDocumentTitle('Climate policy radar | Tools | Something else'),
49
+ ).toBe('Climate policy radar');
50
+ });
40
51
 
41
- describe('InlineDocCard', () => {
42
- beforeEach(() => {
43
- useCatalogueDoc.mockReset();
44
- useCatalogueDoc.mockReturnValue({ result: null, loading: true });
52
+ it('returns trimmed title when no pipe is present', () => {
53
+ expect(cleanDocumentTitle(' Some Title ')).toBe('Some Title');
45
54
  });
46
55
 
47
- it('renders a basic card with the marker title when no document matches', () => {
48
- render(<InlineDocCard title="Unknown Title" documents={[doc]} />);
49
- expect(screen.getByText('Unknown Title')).toBeInTheDocument();
50
- expect(screen.getByText('Document')).toBeInTheDocument();
51
- expect(screen.queryByText('The French NAS.')).not.toBeInTheDocument();
56
+ it('handles null and undefined gracefully', () => {
57
+ expect(cleanDocumentTitle(null)).toBe('');
58
+ expect(cleanDocumentTitle(undefined)).toBe('');
59
+ expect(cleanDocumentTitle(123)).toBe('');
52
60
  });
61
+ });
53
62
 
54
- it('matches the title case-insensitively and enriches with the document metadata', () => {
55
- render(
56
- <InlineDocCard
57
- title=" france: NATIONAL adaptation strategy "
58
- documents={[doc]}
59
- />,
60
- );
61
- expect(screen.getByText('The French NAS.')).toBeInTheDocument();
62
- expect(screen.getByText('01 May 23')).toBeInTheDocument();
63
+ describe('matchesDocumentTitle', () => {
64
+ it('matches identical titles', () => {
65
+ expect(matchesDocumentTitle('Same Title', 'Same Title')).toBe(true);
63
66
  });
64
67
 
65
- it('renders the title as a link for web documents', () => {
66
- render(<InlineDocCard title="Some Web Source" documents={[webDoc]} />);
67
- const link = screen.getByRole('link', { name: 'Some Web Source' });
68
- expect(link).toHaveAttribute('href', 'https://example.com/web');
69
- expect(link).toHaveAttribute('target', '_blank');
70
- expect(screen.getByText('Web')).toBeInTheDocument();
68
+ it('matches case-insensitively and with trimmed whitespace', () => {
69
+ expect(matchesDocumentTitle(' Title One ', 'title one')).toBe(true);
71
70
  });
72
71
 
73
- it('omits the date when updated_at is missing or invalid', () => {
74
- const noDate = { ...doc, updated_at: undefined };
75
- render(
76
- <InlineDocCard title={doc.semantic_identifier} documents={[noDate]} />,
77
- );
78
- expect(screen.queryByText('01 May 23')).not.toBeInTheDocument();
79
- cleanup();
72
+ it('matches noisy document titles with clean search titles', () => {
73
+ expect(
74
+ matchesDocumentTitle(
75
+ 'Nature DEMO | Tools | Discover the key services',
76
+ 'Nature DEMO',
77
+ ),
78
+ ).toBe(true);
79
+ });
80
80
 
81
- const badDate = { ...doc, updated_at: 'not-a-date' };
82
- render(
83
- <InlineDocCard title={doc.semantic_identifier} documents={[badDate]} />,
81
+ it('returns false for non-matching or empty titles', () => {
82
+ expect(matchesDocumentTitle('Alpha', 'Beta')).toBe(false);
83
+ expect(matchesDocumentTitle(null, 'Beta')).toBe(false);
84
+ expect(matchesDocumentTitle('Alpha', '')).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe('InlineDocCard', () => {
89
+ beforeEach(() => {
90
+ useCatalogueDoc.mockReset();
91
+ useCatalogueDoc.mockReturnValue({ result: null, loading: false });
92
+ });
93
+
94
+ it('renders nothing when no document matches', () => {
95
+ const { container } = render(
96
+ <InlineDocCard title="Unknown Title" documents={[doc]} />,
84
97
  );
85
- expect(screen.queryByText('01 May 23')).not.toBeInTheDocument();
86
- cleanup();
98
+ expect(container.firstChild).toBeNull();
99
+ });
87
100
 
88
- render(<InlineDocCard title={doc.semantic_identifier} documents={[]} />);
89
- expect(screen.getByText(doc.semantic_identifier)).toBeInTheDocument();
101
+ it('renders nothing when ES lookup returns empty (not found in ES)', () => {
102
+ useCatalogueDoc.mockReturnValue({
103
+ result: null,
104
+ loading: false,
105
+ });
106
+ const { container } = render(
107
+ <InlineDocCard title={doc.semantic_identifier} documents={[doc]} />,
108
+ );
109
+ expect(container.firstChild).toBeNull();
110
+ expect(screen.queryByTestId('navigator-card')).not.toBeInTheDocument();
90
111
  });
91
112
 
92
- it('upgrades to the full Navigator card once the ES lookup resolves', () => {
113
+ it('renders the Navigator card once the ES lookup resolves with a document', () => {
93
114
  useCatalogueDoc.mockReturnValue({
94
115
  result: { found: true, uid: 'https://example.com/france-nas' },
95
116
  loading: false,
@@ -99,55 +120,123 @@ describe('InlineDocCard', () => {
99
120
  expect(useCatalogueDoc).toHaveBeenCalledWith(doc.link);
100
121
  });
101
122
 
102
- it('cleans pipeline title noise and matches cleanly', () => {
123
+ it('matches title case-insensitively and renders card when ES lookup resolves', () => {
124
+ useCatalogueDoc.mockReturnValue({
125
+ result: { found: true, uid: 'https://example.com/france-nas' },
126
+ loading: false,
127
+ });
128
+ render(
129
+ <InlineDocCard
130
+ title=" france: NATIONAL adaptation strategy "
131
+ documents={[doc]}
132
+ />,
133
+ );
134
+ expect(screen.getByTestId('navigator-card')).toBeInTheDocument();
135
+ expect(useCatalogueDoc).toHaveBeenCalledWith(doc.link);
136
+ });
137
+
138
+ it('cleans pipeline title noise and matches cleanly when ES lookup resolves', () => {
103
139
  const noisyDoc = {
104
140
  ...doc,
105
141
  semantic_identifier:
106
142
  'Climate policy radar | Tools | Discover the key services, thematic features and tools of Climate-ADAPT Climate-ADAPT',
107
143
  };
144
+ useCatalogueDoc.mockReturnValue({
145
+ result: { found: true, uid: doc.link },
146
+ loading: false,
147
+ });
108
148
  render(
109
149
  <InlineDocCard title="Climate policy radar" documents={[noisyDoc]} />,
110
150
  );
111
- expect(screen.getByText('Climate policy radar')).toBeInTheDocument();
112
- expect(screen.getByText('The French NAS.')).toBeInTheDocument();
113
- });
114
-
115
- it('renders a View button when link is present', () => {
116
- render(<InlineDocCard title="Some Web Source" documents={[webDoc]} />);
117
- const viewButton = screen.getByRole('link', { name: 'View' });
118
- expect(viewButton).toHaveAttribute('href', 'https://example.com/web');
119
- expect(viewButton).toHaveClass('ui button primary icon');
151
+ expect(screen.getByTestId('navigator-card')).toBeInTheDocument();
152
+ expect(useCatalogueDoc).toHaveBeenCalledWith(doc.link);
120
153
  });
121
154
 
122
- it('calls the lookup with the document link (or undefined) from the card', () => {
123
- useCatalogueDoc.mockReturnValue({ result: null, loading: true });
124
- render(<InlineDocCard title="No Match" documents={[]} />);
155
+ it('calls useCatalogueDoc with undefined when no match is found, rendering nothing', () => {
156
+ const { container } = render(
157
+ <InlineDocCard title="No Match" documents={[]} />,
158
+ );
125
159
  expect(useCatalogueDoc).toHaveBeenCalledWith(undefined);
160
+ expect(container.firstChild).toBeNull();
126
161
  });
127
162
  });
128
163
 
129
164
  describe('CcaDocCard', () => {
130
- it('matches the marker against the owning message documents via context', () => {
165
+ beforeEach(() => {
166
+ useCatalogueDoc.mockReset();
167
+ useCatalogueDoc.mockReturnValue({ result: null, loading: false });
168
+ });
169
+
170
+ it('matches the marker against owning message documents and renders when ES resolves', () => {
171
+ useCatalogueDoc.mockReturnValue({
172
+ result: { found: true, uid: doc.link },
173
+ loading: false,
174
+ });
131
175
  render(
132
176
  <ChatMessageContext.Provider value={{ documents: [doc] }}>
133
177
  <CcaDocCard title={doc.semantic_identifier} />
134
178
  </ChatMessageContext.Provider>,
135
179
  );
136
- expect(screen.getByText('The French NAS.')).toBeInTheDocument();
180
+ expect(screen.getByTestId('navigator-card')).toBeInTheDocument();
137
181
  });
138
182
 
139
- it('falls back to the basic card when the message context is absent', () => {
140
- render(<CcaDocCard title={doc.semantic_identifier} />);
141
- expect(screen.getByText(doc.semantic_identifier)).toBeInTheDocument();
142
- expect(screen.queryByText('The French NAS.')).not.toBeInTheDocument();
183
+ it('renders nothing when message context is absent', () => {
184
+ const { container } = render(
185
+ <CcaDocCard title={doc.semantic_identifier} />,
186
+ );
187
+ expect(container.firstChild).toBeNull();
143
188
  });
144
189
 
145
- it('falls back to the basic card when the message has no documents', () => {
146
- render(
190
+ it('renders nothing when message has no documents', () => {
191
+ const { container } = render(
147
192
  <ChatMessageContext.Provider value={{ documents: [] }}>
148
193
  <CcaDocCard title={doc.semantic_identifier} />
149
194
  </ChatMessageContext.Provider>,
150
195
  );
151
- expect(screen.queryByText('The French NAS.')).not.toBeInTheDocument();
196
+ expect(container.firstChild).toBeNull();
197
+ });
198
+
199
+ it('renders nothing when the rich catalogue card throws an error (caught by error boundary)', () => {
200
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
201
+
202
+ useCatalogueDoc.mockReturnValue({
203
+ result: { title: 'Crashing Tool' },
204
+ loading: false,
205
+ });
206
+
207
+ mockNavigatorShouldThrow = true;
208
+
209
+ try {
210
+ const { container } = render(
211
+ <ChatMessageContext.Provider value={{ documents: [doc] }}>
212
+ <CcaDocCard title={doc.semantic_identifier} />
213
+ </ChatMessageContext.Provider>,
214
+ );
215
+
216
+ expect(container.firstChild).toBeNull();
217
+ expect(warnSpy).toHaveBeenCalled();
218
+ } finally {
219
+ mockNavigatorShouldThrow = false;
220
+ warnSpy.mockRestore();
221
+ }
222
+ });
223
+ });
224
+
225
+ describe('DocCardErrorBoundary', () => {
226
+ it('renders custom fallback when provided and an error occurs', () => {
227
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
228
+ const ThrowingComponent = () => {
229
+ throw new Error('Test error');
230
+ };
231
+
232
+ const { getByText } = render(
233
+ <DocCardErrorBoundary fallback={<div>Fallback Content</div>}>
234
+ <ThrowingComponent />
235
+ </DocCardErrorBoundary>,
236
+ );
237
+
238
+ expect(getByText('Fallback Content')).toBeInTheDocument();
239
+ expect(warnSpy).toHaveBeenCalled();
240
+ warnSpy.mockRestore();
152
241
  });
153
242
  });