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
8 changes: 0 additions & 8 deletions apps/mobile/src/lib/pr-review/diff/highlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,6 @@ describe('highlightLine', () => {
expect(texts(tokens).join('')).toBe('// hello');
});

it('re-runs the same line through the cache and returns the same tokens', () => {
const a = highlightLine('const x = 1;', 'typescript');
const b = highlightLine('const x = 1;', 'typescript');
// Reference equality is not required, but the tokenized output
// should match exactly so the UI doesn't see visual diffs.
expect(b).toEqual(a);
});

it('falls back to plain text when the highlighter throws', () => {
// unknown language — lowlight throws and we recover.
const tokens = highlightLine('hello world', 'not-a-real-language');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,4 @@ describe('buildDiffLineAccessibilityLabel', () => {
buildDiffLineAccessibilityLabel(makeLine({ type: 'del', oldLine: 2, text: ' \t ' }))
).toBe('Deleted line 2: (empty)');
});

// Regression guard: if a future refactor decouples the label from the
// parsed type, screen readers could report "Added" for a deleted line
// — which would be worse than the color-only fallback it replaces.
it('is derived from the parsed type — same input yields the same label, type drives the word', () => {
const added = makeLine({ type: 'add', newLine: 1, text: 'x' });
const deleted = makeLine({ type: 'del', oldLine: 1, text: 'x' });
expect(buildDiffLineAccessibilityLabel(added)).toBe(buildDiffLineAccessibilityLabel(added));
expect(buildDiffLineAccessibilityLabel(added).startsWith('Added')).toBe(true);
expect(buildDiffLineAccessibilityLabel(deleted).startsWith('Deleted')).toBe(true);
});
});
28 changes: 7 additions & 21 deletions apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';

import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder';
import { type BuildItemsArgs, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
import { parsePatch } from '@/lib/pr-review/diff/parse-patch';
import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';

// Wraps the real parser in a spy so a test can count parse calls.
vi.mock('@/lib/pr-review/diff/parse-patch', async importOriginal => {
const actual = await importOriginal<{ parsePatch: typeof parsePatch }>();
return { ...actual, parsePatch: vi.fn(actual.parsePatch) };
});

type FilePatchMissingItem = Extract<ListItem, { kind: 'file-patch-missing' }>;

type SeparatorItem = Extract<ListItem, { kind: 'expand-separator' }>;
Expand Down Expand Up @@ -144,7 +137,12 @@ describe('buildItems gap separators', () => {
);
const trailing = separatorFor(items, 1);
expect(trailing).toMatchObject({ context: { gapIndex: 1, startLine: 8 }, state: 'idle' });
Comment thread
iscekic marked this conversation as resolved.
expect(Number.isFinite(trailing?.context.endLine ?? 0)).toBe(false);
// The trailing end is unknown, so the expand row must take its
// `isUnknownEnd` branch (default window, no Expand-all affordance).
// Asserted as the builder hands it over; the bounded half of the same
// contract is covered by `uses totalLines to bound the trailing gap once
// known`.
expect(trailing?.context.endLine).toBe(Infinity);
});

it('renders between-hunk separators for gaps larger than 3 lines', () => {
Expand Down Expand Up @@ -298,15 +296,3 @@ describe('buildItems trailing gap totalLines', () => {
});
});
});

describe('buildFileItems parsed patch cache', () => {
it('parses each file object once across two builds that differ only in viewed', () => {
const spy = vi.mocked(parsePatch);
spy.mockClear();
const args = baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } });
const build = (next: BuildItemsArgs) =>
buildItems(next).find((i): i is DiffLineListItem => i.kind === 'diff-line');
expect(build(args)?.parsed).toBe(build({ ...args, viewed: () => true })?.parsed);
expect(spy).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { createElement } from 'react';
import { act } from '@/test/renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
type PrReviewFileListQueryResult,
usePrReviewFileListQuery,
} from '@/lib/pr-review/diff/pr-review-file-list-state';
import { renderWithProviders, waitFor } from '@/test/render-with-providers';

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

const queryFnMock = vi.hoisted(() => vi.fn());

vi.mock('@/lib/trpc', () => ({
useTRPC: () => ({
githubPrReview: {
listFiles: {
infiniteQueryOptions: (input: unknown, opts: object) => ({
...opts,
queryKey: ['githubPrReview', 'listFiles', input],
queryFn: queryFnMock,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage: { nextCursor: string | null }) =>
lastPage.nextCursor ?? undefined,
}),
},
},
}),
}));

// The hook's import chain pulls the real viewed-files store (SecureStore);
// the store is not the subject here, so it is stubbed like the other mounted
// suites for this module do.
vi.mock('@/lib/pr-review/viewed-files', () => ({
getViewedFiles: vi.fn(async () => {
await Promise.resolve();
return [] as string[];
}),
toggleViewedFile: vi.fn(async () => {
await Promise.resolve();
}),
}));

// ── Harness ────────────────────────────────────────────────────────────────

type Result = PrReviewFileListQueryResult;

function Probe({ holder }: { holder: { current: Result | null } }) {
holder.current = usePrReviewFileListQuery({
owner: 'octocat',
repo: 'hello',
number: 1,
enabled: true,
});
return null;
}

async function mountProbe() {
const holder: { current: Result | null } = { current: null };
await renderWithProviders(createElement(Probe, { holder }));
return holder;
}

function current(holder: { current: Result | null }): Result {
const result = holder.current;
if (!result) {
throw new Error('probe did not render');
}
return result;
}

const FILE_A = { path: 'a.ts' };
const FILE_B = { path: 'b.ts' };

beforeEach(() => {
queryFnMock.mockReset();
});

// ── Tests ──────────────────────────────────────────────────────────────────

describe('usePrReviewFileListQuery error classification', () => {
it('a first-page failure surfaces firstPageErrorState and no laterPageError', async () => {
queryFnMock.mockRejectedValue(new Error('network down'));
const holder = await mountProbe();

await waitFor(() => current(holder).errorState !== null);

const result = current(holder);
expect(result.errorState?.kind).toBe('retryable');
expect(result.firstPageErrorState?.kind).toBe('retryable');
expect(result.laterPageError).toBe(false);
expect(result.files).toEqual([]);
});

it('a NOT_FOUND first page classifies as not-found', async () => {
queryFnMock.mockRejectedValue({ data: { code: 'NOT_FOUND' } });
const holder = await mountProbe();

await waitFor(() => current(holder).errorState !== null);

const result = current(holder);
expect(result.errorState?.kind).toBe('not-found');
expect(result.firstPageErrorState?.kind).toBe('not-found');
expect(result.laterPageError).toBe(false);
});

it('a later-page failure keeps the loaded files and flips laterPageError', async () => {
queryFnMock
.mockResolvedValueOnce({ files: [FILE_A], nextCursor: 'cursor-2' })
.mockRejectedValueOnce(new Error('page 2 failed'));
const holder = await mountProbe();

// Load page one, then let the hook auto-run fetch-to-completion? No: the
// hook itself only reads; drive page two through the query handle it
// exposes.
await waitFor(() => current(holder).files.length === 1);
expect(current(holder).files).toEqual([FILE_A]);

await act(async () => {
await current(holder).query.fetchNextPage();
});
await waitFor(() => current(holder).errorState !== null);

const result = current(holder);
expect(result.errorState?.kind).toBe('retryable');
// A page-1 set survived: the tab must not blank on a later-page failure.
expect(result.firstPageErrorState).toBeNull();
expect(result.laterPageError).toBe(true);
expect(result.files).toEqual([FILE_A]);
expect(queryFnMock).toHaveBeenCalledTimes(2);
});

it('loads pages through the provider page shape (sanity on the option wiring)', async () => {
queryFnMock
.mockResolvedValueOnce({ files: [FILE_A], nextCursor: 'cursor-2' })
.mockResolvedValueOnce({ files: [FILE_B], nextCursor: null });
const holder = await mountProbe();

await act(async () => {
await current(holder).query.fetchNextPage();
});

await waitFor(() => current(holder).files.length === 2);
expect(current(holder).files).toEqual([FILE_A, FILE_B]);
expect(current(holder).errorState).toBeNull();
expect(current(holder).firstPageErrorState).toBeNull();
expect(current(holder).laterPageError).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest';

import { buildPrReviewFileListQueryOptions } from './pr-review-file-list-state';
import { PR_REVIEW_MAX_PAGES } from './pr-review-file-types';

// The hook module transitively imports react-native (via
// `@/lib/query/infinite-retention` and the viewed-files store) and the real
Expand All @@ -27,7 +26,11 @@ function createTrpcStub(infiniteQueryOptions: unknown) {
}

describe('buildPrReviewFileListQueryOptions', () => {
it('carries a numeric maxPages bound to PR_REVIEW_MAX_PAGES', () => {
// The literal ceiling, not the constant: this is the documented server page
// cap (60 pages × 50/page = 3,000 files, the same as the truncation banner).
// Comparing the builder's output to the same constant it imports could
// never catch the constant drifting to an unsafe value.
it('caps retention at the server page ceiling of 60', () => {
const infiniteQueryOptions = vi.fn((_input: unknown, options: object) => options);
const result = buildPrReviewFileListQueryOptions(createTrpcStub(infiniteQueryOptions), {
owner: 'octocat',
Expand All @@ -36,6 +39,6 @@ describe('buildPrReviewFileListQueryOptions', () => {
enabled: true,
});

expect(result.maxPages).toBe(PR_REVIEW_MAX_PAGES);
expect(result.maxPages).toBe(60);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export function buildPrReviewFileListQueryOptions(
);
}

/** The mounted contract the Files tab renders from: the raw infinite query plus the classified error states. */
export type PrReviewFileListQueryResult = ReturnType<typeof usePrReviewFileListQuery>;

export function usePrReviewFileListQuery(args: {
owner: string;
repo: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,67 @@ describe('usePrDiffContextLoader', () => {
await waitFor(() => current(holder).expandedContext['src/index.ts']?.[0]?.status === 'partial');
expect(queryFnMock).toHaveBeenCalledTimes(1);
});

it('a provider answer with lines lands as partial with those lines', async () => {
queryFnMock.mockResolvedValue({ lines: ['line-a'], totalLines: 1 });
const holder = await mountProbe();

act(() => {
current(holder).handleLoadContext(ITEM, 20);
});

await waitFor(() => current(holder).expandedContext['src/index.ts']?.[0]?.status === 'partial');
expect(current(holder).expandedContext['src/index.ts']?.[0]).toEqual({
status: 'partial',
lines: ['line-a'],
totalLines: 1,
});
});

it('a provider answer with no lines marks the gap unavailable', async () => {
queryFnMock.mockResolvedValue({ lines: [], totalLines: 0 });
const holder = await mountProbe();

act(() => {
current(holder).handleLoadContext(ITEM, 20);
});

await waitFor(
() => current(holder).expandedContext['src/index.ts']?.[0]?.status === 'unavailable'
);
expect(current(holder).expandedContext['src/index.ts']?.[0]).toEqual({
status: 'unavailable',
});
});

it('a NOT_FOUND rejection marks the gap unavailable, not error', async () => {
queryFnMock.mockRejectedValue({ data: { code: 'NOT_FOUND', message: 'no such ref' } });
const holder = await mountProbe();

act(() => {
current(holder).handleLoadContext(ITEM, 20);
});

await waitFor(
() => current(holder).expandedContext['src/index.ts']?.[0]?.status === 'unavailable'
);
expect(current(holder).expandedContext['src/index.ts']?.[0]).toEqual({
status: 'unavailable',
});
});

it('any other rejection marks the gap error', async () => {
queryFnMock.mockRejectedValue(new Error('boom'));
const holder = await mountProbe();

act(() => {
current(holder).handleLoadContext(ITEM, 20);
});

await waitFor(() => current(holder).expandedContext['src/index.ts']?.[0]?.status === 'error');
expect(current(holder).expandedContext['src/index.ts']?.[0]).toEqual({
status: 'error',
lines: [],
});
});
});
Loading
Loading