Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotcom-rendering/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dependencies": {
"@aws-sdk/client-cloudwatch": "3.995.0",
"@babel/helper-compilation-targets": "7.29.7",
"@braze/web-sdk": "6.5.0",
"@braze/web-sdk": "6.9.0",
"@creditkarma/thrift-server-core": "1.0.4",
"@emotion/cache": "11.14.0",
"@emotion/react": "11.14.0",
Expand Down
113 changes: 113 additions & 0 deletions dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react';
import { isPlacementStale } from '../lib/braze/BrazeBannersSystem';
import type { BrazeInstance } from '../lib/braze/initialiseBraze';
import { useAB } from '../lib/useAB';
import { useBraze } from '../lib/useBraze';
import type { RecipeBlockElement } from '../types/content';
import { ConfigProvider } from './ConfigContext';
import { FeastContextualNudge } from './FeastContextualNudge.island';

jest.mock('../lib/useAB');
jest.mock('../lib/useBraze');
jest.mock('../lib/braze/BrazeBannersSystem', () => ({
BrazeBannersSystemPlacementId: {
FeastContextualNudge1: 'dotcom-rendering_feast-contextual-nudge-1',
FeastContextualNudge2: 'dotcom-rendering_feast-contextual-nudge-2',
FeastContextualNudge3: 'dotcom-rendering_feast-contextual-nudge-3',
FeastContextualNudge4: 'dotcom-rendering_feast-contextual-nudge-4',
FeastContextualNudge5: 'dotcom-rendering_feast-contextual-nudge-5',
},
isPlacementStale: jest.fn(),
BrazeBannersSystemDisplay: ({ meta }: { meta: { id: string } }) => (
<div data-testid="braze-feast-banner" data-banner-id={meta.id} />
),
}));

const recipe: RecipeBlockElement = {
_type: 'model.dotcomrendering.pageElements.RecipeBlockElement',
id: 'recipe-id',
title: 'Test recipe',
};

const braze = {
getBanner: jest.fn(),
} as unknown as BrazeInstance;

const renderNudge = (idApiUrl: string | undefined) =>
render(
<ConfigProvider
value={{
renderingTarget: 'Web',
darkModeAvailable: false,
assetOrigin: '/',
editionId: 'UK',
}}
>
<FeastContextualNudge
recipe={recipe}
recipeArticleTitle="Fallback title"
pageId="food/test"
isDev={false}
nudgeIndex={1}
idApiUrl={idApiUrl}
/>
</ConfigProvider>,
);

describe('FeastContextualNudge Braze fallback', () => {
beforeEach(() => {
jest.mocked(useAB).mockReturnValue({
isUserInTestGroup: () => true,
isUserInTest: () => true,
getParticipations: () => ({}),
trackABTests: () => ({}),
});
jest.mocked(useBraze).mockReturnValue({
braze,
brazeCards: undefined,
brazeMessages: undefined,
});
jest.mocked(isPlacementStale).mockReturnValue(false);
jest.mocked(braze.getBanner).mockReset();
});

it('renders the Braze banner for a fresh eligible placement', () => {
jest.mocked(braze.getBanner).mockReturnValue({} as never);

renderNudge('https://id.test');

expect(screen.getByTestId('braze-feast-banner')).toHaveAttribute(
'data-banner-id',
'feast-contextual-nudge-1',
);
expect(screen.queryByText('Download the app')).not.toBeInTheDocument();
});

it('uses the native Feast card instead of a cached banner when stale', () => {
jest.mocked(isPlacementStale).mockReturnValue(true);
jest.mocked(braze.getBanner).mockReturnValue({} as never);

renderNudge('https://id.test');

expect(screen.getByText('Download the app')).toBeInTheDocument();
expect(
screen.queryByTestId('braze-feast-banner'),
).not.toBeInTheDocument();
expect(braze.getBanner).not.toHaveBeenCalled();
});

it('uses the native Feast card when no banner is eligible', () => {
jest.mocked(braze.getBanner).mockReturnValue(null);

renderNudge('https://id.test');

expect(screen.getByText('Download the app')).toBeInTheDocument();
});

it('does not query Braze when the identity API URL is unavailable', () => {
renderNudge(undefined);

expect(screen.getByText('Download the app')).toBeInTheDocument();
expect(braze.getBanner).not.toHaveBeenCalled();
});
});
64 changes: 61 additions & 3 deletions dotcom-rendering/src/lib/ArticleRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,23 @@ import {
type ArticleFormat,
Pillar,
} from './articleFormat';
import { ArticleRenderer } from './ArticleRenderer';
import { ArticleRenderer, getFeastNudgeIndex } from './ArticleRenderer';

// ── Mocks ─────────────────────────────────────────────────────────────────────

jest.mock('../components/FeastContextualNudge.island', () => ({
FeastContextualNudge: ({ recipe }: { recipe: { id: string } }) => (
<div data-testid="feast-nudge" data-recipe-id={recipe.id} />
FeastContextualNudge: ({
recipe,
nudgeIndex,
}: {
recipe: { id: string };
nudgeIndex: number;
}) => (
<div
data-testid="feast-nudge"
data-recipe-id={recipe.id}
data-nudge-index={nudgeIndex}
/>
),
}));

Expand Down Expand Up @@ -215,6 +225,40 @@ describe('ArticleRenderer — augmentedElements', () => {
expect(nudges).toHaveLength(2);
expect(nudges[0]).toHaveAttribute('data-recipe-id', 'recipe-001');
expect(nudges[1]).toHaveAttribute('data-recipe-id', 'recipe-002');
expect(nudges[0]).toHaveAttribute('data-nudge-index', '1');
expect(nudges[1]).toHaveAttribute('data-nudge-index', '2');
});

it('distributes exactly five numbered placements across a long recipe article', () => {
const elements: FEElement[] = Array.from({ length: 10 }, (_, index) => [
makeSubheading(`<h2>Recipe ${index}</h2>`, index),
makeRecipe(`recipe-${index}`),
]).flat();

renderWithConfig(
<ArticleRenderer
{...defaultProps}
format={recipeFormat}
elements={elements}
/>,
);

const nudges = screen.getAllByTestId('feast-nudge');
expect(nudges).toHaveLength(5);
expect(nudges.map((nudge) => nudge.dataset.recipeId)).toEqual([
'recipe-0',
'recipe-2',
'recipe-5',
'recipe-7',
'recipe-9',
]);
expect(nudges.map((nudge) => nudge.dataset.nudgeIndex)).toEqual([
'1',
'2',
'3',
'4',
'5',
]);
});

it('renders body elements within each section in order', () => {
Expand All @@ -237,3 +281,17 @@ describe('ArticleRenderer — augmentedElements', () => {
expect(screen.getByTestId('article-element-3')).toBeInTheDocument();
});
});

describe('getFeastNudgeIndex', () => {
it('assigns every section when there are at most five', () => {
expect([0, 1, 2].map((index) => getFeastNudgeIndex(index, 3))).toEqual([
1, 2, 3,
]);
});

it('returns null for invalid section indexes', () => {
expect(getFeastNudgeIndex(-1, 3)).toBeNull();
expect(getFeastNudgeIndex(3, 3)).toBeNull();
expect(getFeastNudgeIndex(0, 0)).toBeNull();
});
});
45 changes: 31 additions & 14 deletions dotcom-rendering/src/lib/ArticleRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,33 @@ const commercialPosition = css`
position: relative;
`;

const MAX_FEAST_NUDGES = 5;

/**
* Assigns up to five numbered Braze placements across recipe sections,
* including the first and last section when there are more than five.
*/
export const getFeastNudgeIndex = (
sectionIndex: number,
sectionCount: number,
): number | null => {
if (sectionIndex < 0 || sectionIndex >= sectionCount || sectionCount < 1) {
return null;
}

const nudgeCount = Math.min(MAX_FEAST_NUDGES, sectionCount);
if (nudgeCount === sectionCount) return sectionIndex + 1;

for (let slot = 0; slot < nudgeCount; slot++) {
const targetIndex = Math.round(
(slot * (sectionCount - 1)) / (nudgeCount - 1),
);
if (targetIndex === sectionIndex) return slot + 1;
}

return null;
};

type Props = {
format: ArticleFormat;
elements: FEElement[];
Expand Down Expand Up @@ -158,21 +185,11 @@ export const ArticleRenderer = ({

const result: (JSX.Element | null | undefined)[] = [...preSection];

/**
* Distribute up to 5 Braze placement slots evenly across all sections.
* interval = ceil(sections.length / 5)
* A section at 0-based index i gets nudgeIndex = (i+1)/interval
* only when (i+1) is an exact multiple of interval (and ≤ 5).
* All other sections get nudgeIndex = null (no nudge rendered).
*/
const MAX_NUDGES = 5;
const interval = Math.ceil(sections.length / MAX_NUDGES);

for (const section of sections) {
const position = section.index + 1; // 1-based
const nudgeIndex =
position % interval === 0 ? position / interval : null;

const nudgeIndex = getFeastNudgeIndex(
section.index,
sections.length,
);
result.push(
<Fragment key={`recipe-section-${section.index}`}>
{section.subheadingEl}
Expand Down
Loading
Loading