Skip to content
Merged
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
72 changes: 61 additions & 11 deletions frontend/src/v2/__tests__/V2CatchUpStrip.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
// @ts-nocheck
// The constant agents-TL;DR pinned above the transcript (Sam 2026-09-01).
// Pinned: reads the existing summaries surface, renders a one-line snippet,
// offers Summarize when the pod has none, and a refresh replaces the content.
// Catch-up earns its row from the existing summary's source evidence.
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import V2CatchUpStrip from '../components/V2CatchUpStrip';

jest.mock('axios', () => {
Expand All @@ -20,13 +18,23 @@ jest.mock('axios', () => {
});

const axios = jest.requireMock('axios').default;
const NOW = Date.now();
const DAY = 24 * 60 * 60 * 1000;
const summary = (overrides = {}) => ({
content: 'Three messages worth catching up on.',
createdAt: new Date(NOW - 5 * 60000).toISOString(),
metadata: { totalItems: 3 },
timeRange: { start: new Date(NOW - 3600000).toISOString(), end: new Date(NOW - 5 * 60000).toISOString() },
...overrides,
});

afterEach(() => jest.clearAllMocks());
beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW); });
afterEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); });

describe('V2CatchUpStrip', () => {
test('renders the latest summary as a one-line snippet with its age', async () => {
axios.get.mockResolvedValueOnce({
data: { content: 'Otto verified the token split.\nKai shipped the daemon auth.', createdAt: new Date(Date.now() - 5 * 60000).toISOString() },
data: summary({ content: 'Otto verified the token split.\nKai shipped the daemon auth.' }),
});
render(<V2CatchUpStrip podId="p1" />);
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
Expand All @@ -36,8 +44,8 @@ describe('V2CatchUpStrip', () => {
expect(axios.get).toHaveBeenCalledWith('/api/summaries/pod/p1');
});

test('expanding shows the full body; no summary shows the Summarize action', async () => {
axios.get.mockResolvedValueOnce({ data: { content: 'Line one.\nLine two.', createdAt: new Date().toISOString() } });
test('expanding an eligible summary shows the full body', async () => {
axios.get.mockResolvedValueOnce({ data: summary({ content: 'Line one.\nLine two.' }) });
render(<V2CatchUpStrip podId="p1" />);
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /Catch up/ }));
Expand All @@ -54,9 +62,10 @@ describe('V2CatchUpStrip', () => {
expect(screen.queryByTestId('catchup-strip')).toBeNull();
});

test('a summary older than 24h renders NO strip', async () => {
test('a freshly generated summary covering only July stays hidden', async () => {
axios.get.mockResolvedValueOnce({
data: { content: 'Old news.', createdAt: new Date(Date.now() - 25 * 3600000).toISOString() },
data: summary({ content: 'A quick update: hey guys', createdAt: new Date(NOW).toISOString(), metadata: { totalItems: 1 },
timeRange: { start: '2026-07-01T00:00:00Z', end: '2026-07-01T01:00:00Z' } }),
});
render(<V2CatchUpStrip podId="p4" />);
await waitFor(() => expect(axios.get).toHaveBeenCalled());
Expand All @@ -65,7 +74,7 @@ describe('V2CatchUpStrip', () => {

test('dismiss hides this summary version and persists; the strip stays gone on re-render', async () => {
const createdAt = new Date(Date.now() - 5 * 60000).toISOString();
axios.get.mockResolvedValue({ data: { content: 'Digest.', createdAt } });
axios.get.mockResolvedValue({ data: summary({ content: 'Digest.', createdAt }) });
const { unmount } = render(<V2CatchUpStrip podId="p5" />);
await waitFor(() => expect(screen.getByTestId('catchup-strip')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
Expand All @@ -83,4 +92,45 @@ describe('V2CatchUpStrip', () => {
await waitFor(() => expect(axios.get).toHaveBeenCalled());
expect(screen.queryByTestId('catchup-strip')).toBeNull();
});

test.each([0, 1, 2, undefined, -1, 2.5, '3'])('hides insufficient or invalid source count %s', async (totalItems) => {
axios.get.mockResolvedValue({ data: summary({ metadata: { totalItems } }) });
await act(async () => { render(<V2CatchUpStrip podId="count-boundary" />); });
expect(screen.queryByTestId('catchup-strip')).toBeNull();
});

test.each([
['inside seven days', 7 * DAY - 1, true],
['exactly seven days', 7 * DAY, false],
['past seven days', 7 * DAY + 1, false],
['future window', -1, false],
])('%s uses covered time rather than generation time', async (_label, age, visible) => {
const end = NOW - age;
axios.get.mockResolvedValue({ data: summary({ createdAt: new Date(NOW).toISOString(),
timeRange: { start: new Date(end - 3600000).toISOString(), end: new Date(end).toISOString() } }) });
await act(async () => { render(<V2CatchUpStrip podId="time-boundary" />); });
expect(Boolean(screen.queryByTestId('catchup-strip'))).toBe(visible);
});

test.each([undefined, {}, { start: 'bad', end: 'bad' }, { start: new Date(NOW).toISOString(), end: new Date(NOW - 1).toISOString() }])('hides missing or invalid covered ranges %j', async (timeRange) => {
axios.get.mockResolvedValue({ data: summary({ timeRange }) });
await act(async () => { render(<V2CatchUpStrip podId="invalid-range" />); });
expect(screen.queryByTestId('catchup-strip')).toBeNull();
});

test('refresh cannot replace an eligible strip with a thin or stale one', async () => {
axios.get.mockResolvedValue({ data: summary() });
axios.post.mockResolvedValue({ data: { summary: summary({ metadata: { totalItems: 2 } }) } });
render(<V2CatchUpStrip podId="refresh" />);
await screen.findByTestId('catchup-strip');
fireEvent.click(screen.getByRole('button', { name: /Refresh|Summarize/ }));
await waitFor(() => expect(screen.queryByTestId('catchup-strip')).toBeNull());
});

test('three messages covered six days ago remain eligible even if generated six days ago', async () => {
axios.get.mockResolvedValue({ data: summary({ createdAt: new Date(NOW - 6 * DAY).toISOString(),
timeRange: { start: new Date(NOW - 6 * DAY - 3600000).toISOString(), end: new Date(NOW - 6 * DAY).toISOString() } }) });
render(<V2CatchUpStrip podId="six-days" />);
expect(await screen.findByTestId('catchup-strip')).toBeInTheDocument();
});
});
24 changes: 14 additions & 10 deletions frontend/src/v2/components/V2CatchUpStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import axios from 'axios';
interface PodSummary {
content?: string;
createdAt?: string;
metadata?: { totalItems?: number };
timeRange?: { start?: string; end?: string };
}

interface Props {
Expand Down Expand Up @@ -90,16 +92,18 @@ const V2CatchUpStrip: React.FC<Props> = ({ podId }) => {
}, [podId, summary]);

if (!loaded) return null;
// Sam's revised ruling (2026-09-01, hours after the strip shipped):
// "always shows up is not a good design" and an empty/stale strip
// "reveals no good info". So the strip EARNS its row: it renders only
// when a summary exists, is fresh (24h), and this version has not been
// dismissed. No summary -> no strip, not an empty shell with a
// Summarize button — generating one on demand stays available from the
// inspector/summaries surface.
const FRESH_MS = 24 * 60 * 60 * 1000;
const isFresh = !!(summary?.createdAt && Date.now() - new Date(summary.createdAt).getTime() < FRESH_MS);
if (!summary?.content || !isFresh || dismissed) return null;
// Sam 2026-09-06: at least three source messages and a covered window
// ending within seven days. This supersedes the generation-age-only 24h
// gate: re-generating a July summary today must not make July fresh.
// Legacy summaries without source evidence stay available on the summary
// surface, but do not earn a catch-up strip.
const sourceCount = summary?.metadata?.totalItems;
const sourceStart = Date.parse(summary?.timeRange?.start || '');
const sourceEnd = Date.parse(summary?.timeRange?.end || '');
const sourceAge = Date.now() - sourceEnd;
const isFresh = Number.isFinite(sourceStart) && Number.isFinite(sourceEnd)
&& sourceStart <= sourceEnd && sourceAge >= 0 && sourceAge < 7 * 24 * 60 * 60 * 1000;
if (!summary?.content?.trim() || !Number.isInteger(sourceCount) || (sourceCount ?? 0) < 3 || !isFresh || dismissed) return null;

return (
<div className="v2-catchup" data-testid="catchup-strip">
Expand Down
Loading