The trajectory detail panel showed the conversation id as plain text, so reviewing an example meant copying the room id and hunting for it in the inbox — with the evidence panel open on one screen and the conversation on another. Render it as a link instead. The id has to travel as an owned id (accountId~conversationId): once account runtimes exist, findConversation rejects a bare room id outright, so a plain link would 404 for every multi-account install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.0 KiB
TypeScript
129 lines
5.0 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { HttpResponse, http } from 'msw';
|
|
import { setupServer } from 'msw/node';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
|
|
|
|
const TRAJECTORY_ID = 'traj-1';
|
|
let detailCalls: string[] = [];
|
|
|
|
function summary() {
|
|
return {
|
|
total: 1, provisional: 0, eligible: 1, excluded: 0, invalidated: 0, needsReview: 0,
|
|
humanCreated: 1, humanImproved: 0, humanCompletedAi: 0, autonomous: 0,
|
|
recipientInitiated: 0, uncertain: 0, exact: 1, fingerprint: 0, inferred: 0,
|
|
};
|
|
}
|
|
|
|
function trajectory() {
|
|
return {
|
|
id: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com', conversationId: '!room:reddit.com',
|
|
sentMessageId: 7, subreddit: 'cats', sourceType: 'chat' as const, policyMode: 'r4r-media' as const,
|
|
firstHumanEventId: '$human1', outcomeEventId: '$image1', outcomeType: 'image-received' as const,
|
|
creditClass: 'human-created-success' as const, attributionConfidence: 'exact' as const,
|
|
status: 'eligible' as const, maturityAt: 1_700_000_100_000, invalidatedAt: null,
|
|
invalidationReason: null, reviewedAt: null, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
|
|
};
|
|
}
|
|
|
|
function decision() {
|
|
return {
|
|
id: 'dec-1', trajectoryId: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com',
|
|
decisionEventId: '$human1', stage: 'media-invitation' as const,
|
|
context: [{
|
|
eventId: '$in1', timestamp: 1_700_000_010_000, role: 'recipient' as const,
|
|
actor: 'recipient' as const, kind: 'inbound-text' as const, text: 'hey how was your weekend',
|
|
}],
|
|
chosenResponse: 'no pressure, only if you want to',
|
|
turnsUntilOutcome: 2, isDirectMediaInvitation: true, qualityStatus: 'eligible' as const,
|
|
createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
|
|
};
|
|
}
|
|
|
|
const server = setupServer(
|
|
http.get('/api/learning', () => HttpResponse.json({
|
|
summary: summary(), trajectories: [trajectory()],
|
|
captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1',
|
|
})),
|
|
http.get('/api/learning/trajectories/:id', ({ params }) => {
|
|
detailCalls.push(String(params.id));
|
|
return HttpResponse.json({ trajectory: trajectory(), decisions: [decision()] });
|
|
}),
|
|
);
|
|
|
|
function renderLearning() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<MemoryRouter>
|
|
<LearningPage />
|
|
</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
afterEach(() => {
|
|
server.resetHandlers();
|
|
cleanup();
|
|
});
|
|
afterAll(() => server.close());
|
|
beforeEach(() => {
|
|
detailCalls = [];
|
|
});
|
|
|
|
describe('learning page trajectory detail', () => {
|
|
it('does not fetch decisions until a row is expanded', async () => {
|
|
renderLearning();
|
|
await screen.findByText(/human created success/i);
|
|
expect(detailCalls).toEqual([]);
|
|
});
|
|
|
|
it('reveals the redacted conversation evidence when expanded', async () => {
|
|
renderLearning();
|
|
await screen.findByText(/human created success/i);
|
|
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
|
|
|
|
expect(await screen.findByText('no pressure, only if you want to')).toBeInTheDocument();
|
|
expect(screen.getByText('hey how was your weekend')).toBeInTheDocument();
|
|
expect(screen.getByText('!room:reddit.com')).toBeInTheDocument();
|
|
expect(screen.getByText(/direct media ask/i)).toBeInTheDocument();
|
|
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
|
|
});
|
|
|
|
it('links the evidence to the owning account\'s conversation', async () => {
|
|
renderLearning();
|
|
await screen.findByText(/human created success/i);
|
|
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
|
|
|
|
const link = await screen.findByRole('link', { name: '!room:reddit.com' });
|
|
// The account id has to survive in the owned id or the route 404s.
|
|
expect(link).toHaveAttribute(
|
|
'href',
|
|
`/conversations/${encodeURIComponent('%40t2_learner%3Areddit.com~!room%3Areddit.com')}`,
|
|
);
|
|
});
|
|
|
|
it('collapses the evidence again on a second click', async () => {
|
|
renderLearning();
|
|
await screen.findByText(/human created success/i);
|
|
const toggle = screen.getByRole('button', { name: /show conversation evidence/i });
|
|
await userEvent.click(toggle);
|
|
await screen.findByText('no pressure, only if you want to');
|
|
|
|
await userEvent.click(toggle);
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('no pressure, only if you want to')).not.toBeInTheDocument();
|
|
});
|
|
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
});
|