Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3fcd5bb
feat: guess who
AmarTrebinjac May 6, 2026
520ae33
feat: guess who LLM follow-up phase + analytics
davidercruz May 6, 2026
9fa9bd0
refactor(persona-quiz): replace guess-who with bragi-backed quiz
davidercruz May 13, 2026
5aeaa57
chore(ci): skip strict typecheck for FunnelStepper.tsx
davidercruz May 14, 2026
1e57817
feat(persona-quiz): reading-the-room delay + force LLM to keep asking
davidercruz May 14, 2026
fbb0553
feat(persona-quiz): static Q3 mixed branches + LLM retry
davidercruz May 14, 2026
22e3bf3
feat(persona-quiz): static Q4 mixed branches — finish Q1-Q4 graph
davidercruz May 14, 2026
ddf68ee
feat(persona-quiz): inline feed preview between questions
davidercruz May 14, 2026
00ec3f6
feat(persona-quiz): loading-screen tips for onboarding
davidercruz May 14, 2026
dcdc289
feat(persona-quiz): gate loading on recswipe + hydrate preview cards
davidercruz May 14, 2026
dfd3060
fix(persona-quiz): self-contained preview cards (no AuthContext crash)
davidercruz May 14, 2026
eefc558
style(persona-quiz): match preview card dimensions to feed cards
davidercruz May 14, 2026
da672cb
fix(persona-quiz): unblock reveal after Q14
davidercruz May 14, 2026
72c1693
fix(persona-quiz): tag-based headline fallback when reveal is null
davidercruz May 14, 2026
4edef4c
style(persona-quiz): humanise tag slugs in fallback headline
davidercruz May 14, 2026
2eeb7d7
fix(persona-quiz): send total targetCount, not remaining
davidercruz May 14, 2026
4c1e5e8
feat(persona-quiz): rebuild as fast yes/no decision tree
davidercruz May 18, 2026
34f1304
refactor(persona-quiz): convergent DAG + finite archetype personas (v2)
davidercruz May 18, 2026
dd3a99e
refactor(persona-quiz): 15 questions, reveal uses TagSelection + coll…
davidercruz May 19, 2026
8f05253
refactor(persona-quiz): regenerate DAG with stricter per-phase axis d…
davidercruz May 19, 2026
e664990
feat(persona-quiz): persist tags incrementally + use shared Feed inte…
davidercruz May 19, 2026
e5b8232
fix(persona-quiz): pass tag filter as Feed variables so preview actua…
davidercruz May 20, 2026
ebacafe
fix(persona-quiz): drive inter-question preview via persisted feedSet…
davidercruz May 22, 2026
3da5a55
feat(persona-quiz): branching decision tree + sub-personas, reveal fi…
davidercruz Jun 3, 2026
5e08b00
feat(persona-quiz): cosmic restyle for intro/question/reveal
davidercruz Jun 3, 2026
9c58fdb
feat(persona-quiz): depth-4 sub-personas for data + infra (48 personas)
davidercruz Jun 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
FunnelOrganicSignup,
FunnelBrowserExtension,
FunnelUploadCv,
FunnelPersonaQuiz,
} from '../steps';
import { FunnelFact } from '../steps/FunnelFact';
import { FunnelCheckout } from '../steps/FunnelCheckout';
Expand Down Expand Up @@ -79,6 +80,7 @@ const stepComponentMap = {
[FunnelStepType.PlusCards]: FunnelPlusCards,
[FunnelStepType.BrowserExtension]: FunnelBrowserExtension,
[FunnelStepType.UploadCv]: FunnelUploadCv,
[FunnelStepType.PersonaQuiz]: FunnelPersonaQuiz,
} as const;

function FunnelStepComponent(props: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { FunnelPersonaQuiz } from './index';
import type { FunnelStepPersonaQuiz } from '../../types/funnel';
import { FunnelStepType, FunnelStepTransitionType } from '../../types/funnel';
import { LogEvent } from '../../../../lib/log';

jest.mock('../../../../graphql/common', () => ({
gqlClient: { request: jest.fn().mockResolvedValue({ page: { edges: [] } }) },
}));

// Reveal screen uses TagSelection + Feed primitives that pull in useFeedSettings
// and useTagAndSource. Stub them so the orchestration tests stay focused on the
// quiz flow and don't fail on missing context wiring.
jest.mock('../../../../hooks/useFeedSettings', () => ({
__esModule: true,
default: () => ({ feedSettings: null, isLoading: false }),
getFeedSettingsQueryKey: () => ['feedSettings'],
}));

jest.mock('../../../../hooks/useTagAndSource', () => ({
__esModule: true,
default: () => ({
onFollowTags: jest.fn(),
onUnfollowTags: jest.fn(),
onBlockTags: jest.fn(),
onUnblockTags: jest.fn(),
onFollowSource: jest.fn(),
onUnfollowSource: jest.fn(),
onBlockSource: jest.fn(),
onUnblockSource: jest.fn(),
}),
}));

jest.mock('../../../../hooks/useConditionalFeature', () => ({
useConditionalFeature: () => ({ value: false }),
}));

jest.mock('../../../../contexts/SettingsContext', () => ({
useSettingsContext: () => ({
sidebarExpanded: false,
autoDismissNotifications: false,
}),
ThemeMode: { Light: 'light', Dark: 'dark' },
}));

// Heavy Feed component is exercised by other test suites; here we only need it
// to mount without exploding so the orchestration's inter-question render
// path is covered.
jest.mock('../../../../components/Feed', () => ({
__esModule: true,
default: () => null,
}));

const mockFollowTags = jest.fn().mockResolvedValue(undefined);
jest.mock('../../../../hooks/useMutateFilters', () => ({
__esModule: true,
default: () => ({
followTags: mockFollowTags,
unfollowTags: jest.fn(),
blockTag: jest.fn(),
unblockTag: jest.fn(),
followSource: jest.fn(),
unfollowSource: jest.fn(),
blockSource: jest.fn(),
unblockSource: jest.fn(),
updateAdvancedSettings: jest.fn(),
updateFeedFilters: jest.fn(),
}),
}));

const mockLogEvent = jest.fn();
jest.mock('../../../../contexts/LogContext', () => ({
useLogContext: () => ({ logEvent: mockLogEvent }),
}));

jest.mock('../../../../contexts/AuthContext', () => ({
useAuthContext: () => ({
user: { id: 'u1', email: 'a@b.c' },
trackingId: 'u1',
}),
}));

jest.mock('../../../../hooks/useTagSearch', () => ({
__esModule: true,
MIN_SEARCH_QUERY_LENGTH: 2,
useTagSearch: () => ({
data: {
searchTags: { tags: [{ name: 'graphql' }, { name: 'rust' }], query: '' },
},
isLoading: false,
}),
}));

const parameters: FunnelStepPersonaQuiz['parameters'] = {
entryQuestionId: 'q_domain',
questions: [
{
id: 'q_domain',
axis: 'domain',
prompt: 'Where do you spend most time?',
options: [
{
id: 'frontend',
label: 'Frontend',
tagWeights: { react: 1, tailwind: 1 },
next: 'q_fe_yn',
},
{
id: 'backend',
label: 'Backend',
tagWeights: { nodejs: 1, postgres: 1 },
next: 'q_be_yn',
},
],
},
{
id: 'q_fe_yn',
axis: 'fe_typescript',
prompt: 'You write TypeScript.',
archetypeId: 'frontend_dev',
options: [
{
id: 'yes',
label: 'Yes',
tagWeights: { typescript: 1 },
next: null,
},
{
id: 'no',
label: 'No',
tagWeights: { javascript: 1 },
next: null,
},
],
},
{
id: 'q_be_yn',
axis: 'be_go',
prompt: 'Your main backend language is Go.',
archetypeId: 'backend_dev',
options: [
{
id: 'yes',
label: 'Yes',
tagWeights: { go: 1, golang: 1 },
next: null,
},
{
id: 'no',
label: 'No',
tagWeights: { python: 1 },
next: null,
},
],
},
],
selection: {
maxQuestions: 15,
targetTotalTags: 6,
tagConfidenceFloor: 1,
fallbackTags: ['javascript'],
},
archetypes: [
{
id: 'frontend_dev',
name: 'Frontend Dev',
headline: 'TypeScript frontend dev',
description: 'Heavy TS + React feed coming up.',
// `design-systems` isn't produced by any answer below — it can only end
// up in the final tags if the archetype's keyTags seed the list.
keyTags: ['design-systems', 'react', 'typescript', 'tailwind'],
},
{
id: 'backend_dev',
name: 'Backend Dev',
headline: 'Backend builder shipping services',
description: 'API and service feed incoming.',
keyTags: ['nodejs', 'postgres'],
},
],
reveal: {
eyebrow: 'You are a…',
cta: 'Looks good',
feedbackCta: 'Nope, not me',
},
};

const baseStep: FunnelStepPersonaQuiz = {
id: 'persona-quiz-step',
type: FunnelStepType.PersonaQuiz,
transitions: [
{
on: FunnelStepTransitionType.Complete,
destination: 'next',
},
],
isActive: true,
parameters,
onTransition: jest.fn(),
};

const renderStep = (overrides: Partial<FunnelStepPersonaQuiz> = {}) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const onTransition = jest.fn();
const props = { ...baseStep, ...overrides, onTransition };
const utils = render(
<QueryClientProvider client={queryClient}>
<FunnelPersonaQuiz {...props} />
</QueryClientProvider>,
);
return { ...utils, onTransition };
};

describe('FunnelPersonaQuiz', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('logs StartPersonaQuiz on mount', async () => {
renderStep();
await waitFor(() => {
expect(mockLogEvent).toHaveBeenCalledWith(
expect.objectContaining({ event_name: LogEvent.StartPersonaQuiz }),
);
});
});

it('shows an intro screen and starts the quiz on Begin when a headline is set', async () => {
renderStep({
parameters: {
...parameters,
headline: 'Let me guess who you are',
explainer: 'A few quick questions.',
},
});
expect(
await screen.findByText('Let me guess who you are'),
).toBeInTheDocument();
expect(
screen.queryByText('Where do you spend most time?'),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Begin'));
expect(
await screen.findByText('Where do you spend most time?'),
).toBeInTheDocument();
});

it('walks Q→A→reveal via static `next` pointers and emits Complete with payload', async () => {
const { onTransition } = renderStep();
fireEvent.click(await screen.findByText('Frontend'));
fireEvent.click(await screen.findByText('Yes'));
expect(
await screen.findByText('TypeScript frontend dev'),
).toBeInTheDocument();
fireEvent.click(screen.getByText('Looks good'));
await waitFor(() => {
// Tags are followed incrementally during the quiz — collect the union
// of every `followTags` call so we can assert the full quiz tag set.
const allFollowedTags = mockFollowTags.mock.calls.flatMap(
([{ tags }]) => tags,
);
expect(allFollowedTags).toEqual(
expect.arrayContaining(['react', 'tailwind', 'typescript']),
);
});
await waitFor(() => {
expect(onTransition).toHaveBeenCalledWith(
expect.objectContaining({
type: FunnelStepTransitionType.Complete,
details: expect.objectContaining({
quizAnswers: [
{ questionId: 'q_domain', optionId: 'frontend' },
{ questionId: 'q_fe_yn', optionId: 'yes' },
],
}),
}),
);
});
});

it('seeds the final tag list with the resolved archetype keyTags', async () => {
const { onTransition } = renderStep();
fireEvent.click(await screen.findByText('Frontend'));
fireEvent.click(await screen.findByText('Yes'));
expect(
await screen.findByText('TypeScript frontend dev'),
).toBeInTheDocument();
fireEvent.click(screen.getByText('Looks good'));
await waitFor(() => {
expect(onTransition).toHaveBeenCalledWith(
expect.objectContaining({
type: FunnelStepTransitionType.Complete,
details: expect.objectContaining({
tags: expect.arrayContaining(['design-systems']),
}),
}),
);
});
});

it('falls back to a tag-based headline when no archetype is resolved', async () => {
renderStep({
parameters: {
...parameters,
// Terminal question has no archetypeId set — orchestration should fall back.
questions: parameters.questions.map((q) =>
q.id === 'q_be_yn' ? { ...q, archetypeId: undefined } : q,
),
},
});
fireEvent.click(await screen.findByText('Backend'));
fireEvent.click(await screen.findByText('No'));
const heading = await screen.findByRole('heading', { level: 2 });
expect(heading).toHaveTextContent(/Nodejs/);
expect(heading).toHaveTextContent(/locked in/);
});

it('opens the feedback form and logs PersonaQuizFeedback with the reveal headline', async () => {
renderStep();
fireEvent.click(await screen.findByText('Frontend'));
fireEvent.click(await screen.findByText('Yes'));
fireEvent.click(await screen.findByText('Nope, not me'));
const textarea = await screen.findByPlaceholderText(
/Tell us what we got wrong/i,
);
fireEvent.change(textarea, { target: { value: "I'm a backend dev" } });
fireEvent.click(screen.getByText('Send feedback'));
await waitFor(() => {
expect(mockLogEvent).toHaveBeenCalledWith(
expect.objectContaining({
event_name: LogEvent.PersonaQuizFeedback,
extra: expect.stringContaining("I'm a backend dev"),
}),
);
});
const feedbackCall = mockLogEvent.mock.calls.find(
([call]) => call?.event_name === LogEvent.PersonaQuizFeedback,
);
expect(feedbackCall?.[0]?.extra).toContain('TypeScript frontend dev');
});
});
Loading
Loading