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
198 changes: 198 additions & 0 deletions apps/web/__tests__/unit/transcript-sidebar-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// @vitest-environment jsdom

import type { Video } from "@cap/web-domain";
import { act, createElement, type RefObject } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getEditTranscript: vi.fn(),
scrollToIndex: vi.fn(),
}));

vi.mock("@/actions/videos/get-edit-transcript", () => ({
getEditTranscript: mocks.getEditTranscript,
requestEditTranscript: vi.fn(),
}));

vi.mock("@/app/s/[videoId]/edit/use-active-transcript-word-index", () => ({
useActiveTranscriptWordIndex: () => -1,
}));

vi.mock("@virtual-grid/react", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 100,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 100,
})),
measureElement: vi.fn(),
scrollToIndex: mocks.scrollToIndex,
}),
}));

import { TranscriptSidebar } from "@/app/s/[videoId]/edit/TranscriptSidebar";

const videoId = "video-id" as Video.VideoId;
const transcript = {
version: 3 as const,
speechModelUsed: "fixture",
durationMs: 5_000,
languageCode: "en",
words: [
{
id: "word-0",
text: "Match",
startMs: 1_000,
endMs: 1_400,
confidence: 1,
speaker: null,
channel: null,
},
{
id: "word-1",
text: "between",
startMs: 2_000,
endMs: 2_400,
confidence: 1,
speaker: null,
channel: null,
},
{
id: "word-2",
text: "matching",
startMs: 3_000,
endMs: 3_400,
confidence: 1,
speaker: null,
channel: null,
},
],
};

let container: HTMLDivElement;
let root: Root;
let video: HTMLVideoElement;

async function waitFor(assertion: () => void) {
let error: unknown;
for (let attempt = 0; attempt < 50; attempt++) {
try {
assertion();
return;
} catch (cause) {
error = cause;
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
throw error;
}

async function enterSearch(query: string, expectedCounter = "1/2") {
const input = container.querySelector<HTMLInputElement>(
'input[placeholder="Search transcript"]',
);
if (!input) throw new Error("Search input missing");

await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(input, query);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await waitFor(() => expect(container.textContent).toContain(expectedCounter));
return input;
}

async function pressEnter(input: HTMLInputElement) {
await act(async () => {
input.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }),
);
});
}

beforeEach(async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
HTMLElement.prototype.scrollIntoView = vi.fn();
mocks.getEditTranscript.mockResolvedValue({ status: "ready", transcript });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
video = document.createElement("video");
const videoRef: RefObject<HTMLVideoElement | null> = { current: video };

await act(async () => {
root.render(
createElement(TranscriptSidebar, {
videoId,
videoRef,
keepRanges: [{ start: 0, end: 5 }],
onDeleteRanges: vi.fn(),
}),
);
});
await waitFor(() =>
expect(
container.querySelector('input[placeholder="Search transcript"]'),
).not.toBeNull(),
);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.clearAllMocks();
});

describe("transcript search navigation", () => {
it("seeks to the first match before advancing through later matches", async () => {
const input = await enterSearch("match");

await pressEnter(input);
expect(video.currentTime).toBe(1);
expect(container.textContent).toContain("1/2");

await pressEnter(input);
expect(video.currentTime).toBe(3);
expect(container.textContent).toContain("2/2");

await pressEnter(input);
expect(video.currentTime).toBe(1);
expect(container.textContent).toContain("1/2");
Comment on lines +159 to +173

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Search paths lack test coverage

The new test only presses Enter on one unchanged query. It does not cover starting with Previous or changing the query after selecting a match, both of which depend on the new -1 state. Those paths could break while this test still passes. Add checks that fresh Previous seeks the last match and that the first Next after changing the query seeks the new first match.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/__tests__/unit/transcript-sidebar-search.test.ts
Line: 159-173

Comment:
**Search paths lack test coverage**

The new test only presses Enter on one unchanged query. It does not cover starting with Previous or changing the query after selecting a match, both of which depend on the new `-1` state. Those paths could break while this test still passes. Add checks that fresh Previous seeks the last match and that the first Next after changing the query seeks the new first match.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added both, Previous on a fresh search seeks the last match and the first Next after changing the query seeks the new first match. Both pass with the current logic so no component change was needed (4fe95e0).

});

it("seeks to the last match when Previous is pressed first", async () => {
await enterSearch("match");
const previous = container.querySelector<HTMLButtonElement>(
'button[aria-label="Previous search result"]',
);
if (!previous) throw new Error("Previous search button missing");

await act(async () => previous.click());
expect(video.currentTime).toBe(3);
expect(container.textContent).toContain("2/2");
});

it("seeks to the new first match after changing the query", async () => {
const input = await enterSearch("between", "1/1");
await pressEnter(input);
expect(video.currentTime).toBe(2);

await enterSearch("match");
await pressEnter(input);
expect(video.currentTime).toBe(1);
expect(container.textContent).toContain("1/2");
});
});
23 changes: 15 additions & 8 deletions apps/web/app/s/[videoId]/edit/TranscriptSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function TranscriptSidebar({
endIndex: number;
} | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const [searchMatchIndex, setSearchMatchIndex] = useState(-1);
const [previewFillers, setPreviewFillers] = useState(false);
const deferredSearchQuery = useDeferredValue(searchQuery);
const dragAnchorRef = useRef<number | null>(null);
Expand Down Expand Up @@ -465,9 +465,14 @@ export function TranscriptSidebar({
const moveSearchMatch = useCallback(
(direction: -1 | 1) => {
if (searchMatches.length === 0) return;
const next =
(searchMatchIndex + direction + searchMatches.length) %
searchMatches.length;
let next: number;
if (searchMatchIndex === -1) {
next = direction === 1 ? 0 : searchMatches.length - 1;
} else {
next =
(searchMatchIndex + direction + searchMatches.length) %
searchMatches.length;
}
setSearchMatchIndex(next);
const wordIndex = searchMatches[next];
if (wordIndex === undefined) return;
Expand Down Expand Up @@ -666,7 +671,7 @@ export function TranscriptSidebar({
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchMatchIndex(0);
setSearchMatchIndex(-1);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
Expand All @@ -678,7 +683,7 @@ export function TranscriptSidebar({
event.preventDefault();
event.stopPropagation();
setSearchQuery("");
setSearchMatchIndex(0);
setSearchMatchIndex(-1);
}
}}
placeholder="Search transcript"
Expand All @@ -687,8 +692,10 @@ export function TranscriptSidebar({
{searchQuery && (
<>
<span className="shrink-0 text-[10px] font-medium tabular-nums text-gray-9">
{searchMatches.length === 0 ? 0 : searchMatchIndex + 1}/
{searchMatches.length}
{searchMatches.length === 0
? 0
: Math.max(searchMatchIndex + 1, 1)}
/{searchMatches.length}
</span>
<button
type="button"
Expand Down
Loading