GitNexus/gitnexus-web/test/unit/use-auto-scroll.test.tsx
Gergő Magyar 759c983dce
fix(extractors): resolve 3 silent contract mis-resolution bugs (#793) (#817)
* fix(extractors): resolve 3 silent contract mis-resolution bugs (#793)

Addresses Codex adversarial review findings for extractor contract
resolution on the new group extractor surface.

F1 (manifest-extractor): resolveSymbol passed the full "METHOD::path"
contract string through normalizeRoutePath, producing "/GET::/api/orders"
which never matches Route.name. Adds parseHttpContract() helper that
strips the METHOD:: prefix before path normalization. Contract ID
construction (buildContractId) is unchanged.

F2 (http-route-extractor): graph-assisted backfill used path-only
detections.find(), so multi-verb same-URL files attached the wrong
verb/handler to provider rows and inferred the wrong verb on FETCHES
consumer edges. Now requires path+method match when method is known,
and skips backfill when method is unknown and multiple detections tie
on path.

F3 (grpc-extractor): resolveProtoConflict seeded bestScore=-1 and only
replaced on strict >, so all-zero-score ties silently selected
candidates[0]. Now computes all scores, counts ties at the top score,
and returns null on ambiguity (caller skips contract emission and
warns with service name + candidate paths).

All three fixes are test-first; 73 tests pass across the three suites.
No schema changes, no new dependencies, contract ID wire format
(http::METHOD::path, grpc::pkg.Service/Method, http::*::path) preserved.

* fix(extractors): address PR #817 review — ambiguous symbol pick + contract id casing

Copilot + Claude review on PR #817 flagged two follow-up bugs on top of
the F1/F2/F3 fixes:

1. http-route-extractor: ambiguous multi-verb case left handlerName null
   but still ran the CONTAINS DB query. pickSymbolUid(syms, null) then
   silently picked pool[0] — reintroducing handler mis-attribution via
   a different route than the .find() bug F2 fixed. Now gates symbol
   enrichment on an ambiguousCandidates flag so the file-basename
   fallback wins instead.

2. manifest-extractor: buildContractId passed raw user casing through
   for the explicit-method form, so get::/api/orders and
   GET::/api/orders produced different contract ids even though
   parseHttpContract upper-cases during lookup. Now reuses
   parseHttpContract + normalizeRoutePath to canonicalize both method
   and path, so logically equivalent manifest inputs share a contract
   id (and share a manifestSymbolUid fallback).

Adds one regression test per bug: lowercase vs uppercase manifest
contract ids must match, and ambiguous multi-verb with CONTAINS rows
must not silently attach a real handler or call the CONTAINS query
at all. 75 tests pass across the three extractor suites.

* chore: prettier formatting
2026-04-14 08:03:02 +01:00

287 lines
10 KiB
TypeScript

import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAutoScroll } from '../../src/hooks/useAutoScroll';
interface HarnessProps {
messages: unknown[];
isChatLoading: boolean;
}
function AutoScrollHarness({ messages, isChatLoading }: HarnessProps) {
const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll(
messages,
isChatLoading,
);
return (
<>
<div data-testid="is-at-bottom">{String(isAtBottom)}</div>
<div data-testid="container" ref={scrollContainerRef}>
{messages.length > 0 ? (
<div data-testid="messages-container" ref={messagesContainerRef}>
{messages.map((message, index) => (
<div key={index}>{String(message)}</div>
))}
</div>
) : null}
</div>
<button type="button" onClick={() => scrollToBottom()}>
Scroll to bottom
</button>
</>
);
}
function setScrollMetrics(
element: HTMLDivElement,
metrics: { scrollTop?: number; scrollHeight?: number; clientHeight?: number },
) {
if (metrics.scrollTop !== undefined) {
Object.defineProperty(element, 'scrollTop', {
configurable: true,
writable: true,
value: metrics.scrollTop,
});
}
if (metrics.scrollHeight !== undefined) {
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
value: metrics.scrollHeight,
});
}
if (metrics.clientHeight !== undefined) {
Object.defineProperty(element, 'clientHeight', {
configurable: true,
value: metrics.clientHeight,
});
}
}
async function flushAnimationFrame() {
await act(async () => {
vi.runAllTimers();
});
}
async function scrollContainer(element: HTMLDivElement, scrollTop: number) {
setScrollMetrics(element, { scrollTop });
fireEvent.scroll(element);
await flushAnimationFrame();
}
const resizeObserverInstances: ResizeObserverMock[] = [];
class ResizeObserverMock {
callback: ResizeObserverCallback;
observedElements: Element[] = [];
observe = vi.fn((element: Element) => {
this.observedElements.push(element);
});
unobserve = vi.fn();
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObserverInstances.push(this);
}
}
async function triggerResize(instance: ResizeObserverMock) {
await act(async () => {
instance.callback([], instance as unknown as ResizeObserver);
});
await flushAnimationFrame();
}
describe('useAutoScroll', () => {
beforeEach(() => {
vi.useFakeTimers();
resizeObserverInstances.length = 0;
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
return window.setTimeout(() => callback(performance.now()), 0);
}),
);
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((frameId: number) => {
clearTimeout(frameId);
}),
);
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: function (options: ScrollToOptions) {
if (options.top !== undefined) {
Object.defineProperty(this, 'scrollTop', {
configurable: true,
writable: true,
value: options.top,
});
}
},
});
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('starts with isAtBottom true and auto-scrolls the very first message', () => {
const { rerender } = render(<AutoScrollHarness messages={[]} isChatLoading={false} />);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 0, scrollHeight: 500, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
expect(container.scrollTop).toBe(500);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('follows streaming updates while the view stays pinned to the bottom', () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(1000);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('stops auto-scroll after the user scrolls up', async () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(250);
});
it('re-enables auto-scroll once the user returns near the bottom', async () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1400, clientHeight: 200 });
await scrollContainer(container, 1120);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
setScrollMetrics(container, { scrollTop: 1120, scrollHeight: 1800, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(1800);
});
it('scrollToBottom re-engages auto-scroll and scrolls to the container bottom', async () => {
const { rerender } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
const scrollTo = vi.spyOn(container, 'scrollTo');
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
fireEvent.click(screen.getByRole('button', { name: 'Scroll to bottom' }));
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1600, clientHeight: 200 });
rerender(<AutoScrollHarness messages={[{ id: 1 }, { id: 2 }]} isChatLoading={true} />);
expect(container.scrollTop).toBe(1600);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('re-pins to the latest bottom when inner content grows asynchronously', async () => {
render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
setScrollMetrics(container, { scrollTop: 1000, scrollHeight: 1450, clientHeight: 200 });
await triggerResize(resizeObserverInstances[0]);
expect(container.scrollTop).toBe(1450);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('true');
});
it('does not auto-scroll on async growth after user intentionally scrolls away', async () => {
render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 700, scrollHeight: 1000, clientHeight: 200 });
await scrollContainer(container, 700);
await scrollContainer(container, 250);
setScrollMetrics(container, { scrollTop: 250, scrollHeight: 1400, clientHeight: 200 });
await triggerResize(resizeObserverInstances[0]);
expect(container.scrollTop).toBe(250);
expect(screen.getByTestId('is-at-bottom')).toHaveTextContent('false');
});
it('cancels the pending ResizeObserver rAF when the component unmounts', () => {
const cancelRAF = vi.mocked(cancelAnimationFrame);
const { unmount } = render(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const container = screen.getByTestId('container') as HTMLDivElement;
setScrollMetrics(container, { scrollTop: 950, scrollHeight: 1000, clientHeight: 200 });
const callsBefore = cancelRAF.mock.calls.length;
act(() => {
resizeObserverInstances[0].callback(
[],
resizeObserverInstances[0] as unknown as ResizeObserver,
);
});
unmount();
expect(cancelRAF.mock.calls.length).toBeGreaterThan(callsBefore);
expect(() => vi.runAllTimers()).not.toThrow();
});
it('attaches the observer when the messages wrapper first appears and disconnects on unmount', () => {
const { rerender, unmount } = render(<AutoScrollHarness messages={[]} isChatLoading={false} />);
expect(screen.queryByTestId('messages-container')).toBeNull();
expect(resizeObserverInstances).toHaveLength(0);
rerender(<AutoScrollHarness messages={[{ id: 1 }]} isChatLoading={false} />);
const messagesContainer = screen.getByTestId('messages-container');
const resizeObserver = resizeObserverInstances[0];
expect(resizeObserverInstances).toHaveLength(1);
expect(resizeObserver.observe).toHaveBeenCalledWith(messagesContainer);
unmount();
expect(resizeObserver.disconnect).toHaveBeenCalledTimes(1);
});
});