Skip to content
Open
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
136 changes: 136 additions & 0 deletions src/components/launch/NotesToolbar.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import "@/index.css";
import type { Editor } from "@tiptap/react";
import { useState } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { NotesToolbar } from "./NotesToolbar";

vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => children,
}));

vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({ locale: "en" }),
useScopedT: () => (key: string, vars?: Record<string, string | number>) => {
const labels: Record<string, string> = {
"tooltips.notesToolbar.play": "Play",
"tooltips.notesToolbar.pause": "Pause",
"tooltips.notesToolbar.speed": "Scroll speed",
"tooltips.notesToolbar.decreaseSpeed": "Decrease scroll speed",
"tooltips.notesToolbar.increaseSpeed": "Increase scroll speed",
"tooltips.notesToolbar.fontSize": "Font size",
"tooltips.notesToolbar.decreaseFontSize": "Decrease font size",
"tooltips.notesToolbar.increaseFontSize": "Increase font size",
"tooltips.notesToolbar.mirror": "Mirror",
"units.pixelsPerSecond": "{{value}} px/s",
"units.pixels": "{{value}} px",
};
return (labels[key] ?? key).replace(/\{\{(\w+)\}\}/g, (_, name: string) =>
String(vars?.[name] ?? `{{${name}}}`),
);
},
}));

function createEditor(): Editor {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
for (const command of [
"focus",
"toggleBold",
"toggleItalic",
"toggleStrike",
"toggleBulletList",
"toggleOrderedList",
"toggleBlockquote",
"toggleCodeBlock",
]) {
chain[command] = vi.fn(() => chain);
}
chain.run = vi.fn(() => true);

return {
can: () => ({ chain: () => chain }),
chain: () => chain,
isActive: () => false,
on: vi.fn(),
off: vi.fn(),
} as unknown as Editor;
}

function ToolbarHarness() {
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeed] = useState(40);
const [fontSize, setFontSize] = useState(16);
const [mirrored, setMirrored] = useState(false);

return (
<NotesToolbar
editor={createEditor()}
isPlaying={isPlaying}
speed={speed}
fontSize={fontSize}
mirrored={mirrored}
onTogglePlaying={() => setIsPlaying((current) => !current)}
onDecreaseSpeed={() => setSpeed((current) => current - 10)}
onIncreaseSpeed={() => setSpeed((current) => current + 10)}
onDecreaseFontSize={() => setFontSize((current) => current - 2)}
onIncreaseFontSize={() => setFontSize((current) => current + 2)}
onToggleMirror={() => setMirrored((current) => !current)}
/>
);
}

describe("NotesToolbar narrow-width reachability", () => {
let root: Root | null = null;
let host: HTMLDivElement | null = null;

afterEach(() => {
root?.unmount();
host?.remove();
root = null;
host = null;
});

it("keeps every teleprompter control reachable at a severe 160px width", async () => {
host = document.createElement("div");
host.style.width = "160px";
document.body.append(host);
root = createRoot(host);
flushSync(() => root?.render(<ToolbarHarness />));

const row = host.querySelector<HTMLElement>('[data-testid="notes-teleprompter-controls"]');
const controls = Array.from(
host.querySelectorAll<HTMLButtonElement>("[data-teleprompter-control]"),
);
if (!row || controls.length === 0) {
throw new Error("Teleprompter controls did not render");
}

expect(row.scrollWidth).toBeGreaterThan(row.clientWidth);
await userEvent.wheel(row, { delta: { x: row.scrollWidth } });
await new Promise(requestAnimationFrame);
expect(row.scrollLeft).toBeGreaterThan(0);

const rowRect = row.getBoundingClientRect();
const lastRect = controls.at(-1)?.getBoundingClientRect();
expect(lastRect).toBeDefined();
expect(lastRect!.left).toBeGreaterThanOrEqual(rowRect.left - 1);
expect(lastRect!.right).toBeLessThanOrEqual(rowRect.right + 1);

controls[0]?.focus();
expect(document.activeElement).toBe(controls[0]);
for (let index = 1; index < controls.length; index++) {
await userEvent.tab();
expect(document.activeElement).toBe(controls[index]);
}

controls[0]?.focus();
await userEvent.keyboard("{Enter}");
expect(controls[0]?.getAttribute("aria-label")).toBe("Pause");

const mirror = controls.at(-1)!;
await userEvent.click(mirror);
expect(mirror.getAttribute("aria-pressed")).toBe("true");
});
});
144 changes: 144 additions & 0 deletions src/components/launch/NotesToolbar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import "@testing-library/jest-dom";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Editor } from "@tiptap/react";
import { type ReactNode, useLayoutEffect } from "react";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider, useI18n } from "@/contexts/I18nContext";
import { NotesToolbar, type NotesToolbarProps } from "./NotesToolbar";

vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => children,
}));

function createEditor(): Editor {
const chain: Record<string, ReturnType<typeof vi.fn>> = {};
for (const command of [
"focus",
"toggleBold",
"toggleItalic",
"toggleStrike",
"toggleBulletList",
"toggleOrderedList",
"toggleBlockquote",
"toggleCodeBlock",
]) {
chain[command] = vi.fn(() => chain);
}
chain.run = vi.fn(() => true);

return {
can: () => ({ chain: () => chain }),
chain: () => chain,
isActive: () => false,
on: vi.fn(),
off: vi.fn(),
} as unknown as Editor;
}

function createProps(overrides: Partial<NotesToolbarProps> = {}): NotesToolbarProps {
return {
editor: createEditor(),
isPlaying: false,
speed: 40,
fontSize: 16,
mirrored: false,
onTogglePlaying: vi.fn(),
onDecreaseSpeed: vi.fn(),
onIncreaseSpeed: vi.fn(),
onDecreaseFontSize: vi.fn(),
onIncreaseFontSize: vi.fn(),
onToggleMirror: vi.fn(),
...overrides,
};
}

function ActiveLocale({ children, locale }: { children: ReactNode; locale: string }) {
const { setLocale } = useI18n();

useLayoutEffect(() => {
setLocale(locale);
}, [locale, setLocale]);

return children;
}

function renderToolbar(props: NotesToolbarProps, locale = "en") {
return render(
<I18nProvider>
<ActiveLocale locale={locale}>
<NotesToolbar {...props} />
</ActiveLocale>
</I18nProvider>,
);
}

describe("NotesToolbar teleprompter controls", () => {
it("exposes values and dispatches every manual control", async () => {
const user = userEvent.setup();
const props = createProps();
renderToolbar(props);

const speed = within(screen.getByRole("group", { name: "Scroll speed" })).getByRole("status");
const fontSize = within(screen.getByRole("group", { name: "Font size" })).getByRole("status");
expect(speed).not.toHaveAccessibleName();
expect(speed).toHaveTextContent("40 px/s");
expect(fontSize).not.toHaveAccessibleName();
expect(fontSize).toHaveTextContent("16 px");
expect(screen.getByRole("button", { name: "Mirror horizontally" })).toHaveAttribute(
"aria-pressed",
"false",
);
expect(screen.getByRole("button", { name: "Decrease scroll speed" })).not.toHaveAttribute(
"aria-pressed",
);
expect(screen.getByRole("button", { name: "Increase font size" })).not.toHaveAttribute(
"aria-pressed",
);

await user.click(screen.getByRole("button", { name: "Start auto-scroll" }));
await user.click(screen.getByRole("button", { name: "Decrease scroll speed" }));
await user.click(screen.getByRole("button", { name: "Increase scroll speed" }));
await user.click(screen.getByRole("button", { name: "Decrease font size" }));
await user.click(screen.getByRole("button", { name: "Increase font size" }));
await user.click(screen.getByRole("button", { name: "Mirror horizontally" }));

expect(props.onTogglePlaying).toHaveBeenCalledOnce();
expect(props.onDecreaseSpeed).toHaveBeenCalledOnce();
expect(props.onIncreaseSpeed).toHaveBeenCalledOnce();
expect(props.onDecreaseFontSize).toHaveBeenCalledOnce();
expect(props.onIncreaseFontSize).toHaveBeenCalledOnce();
expect(props.onToggleMirror).toHaveBeenCalledOnce();
});

it("disables controls at their bounds", () => {
const { rerender } = renderToolbar(createProps({ speed: 10, fontSize: 14 }));
expect(screen.getByRole("button", { name: "Decrease scroll speed" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Decrease font size" })).toBeDisabled();

rerender(
<I18nProvider>
<NotesToolbar {...createProps({ speed: 100, fontSize: 48 })} />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Increase scroll speed" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Increase font size" })).toBeDisabled();
});

it("keeps playback paused and disabled until the editor is ready", () => {
renderToolbar(createProps({ editor: null }));
expect(screen.getByRole("button", { name: "Start auto-scroll" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Pause auto-scroll" })).not.toBeInTheDocument();
});

it("formats readout values for the active locale", () => {
renderToolbar(createProps(), "ar");
const speed = within(screen.getByRole("group", { name: "سرعة التمرير" })).getByRole("status");
const fontSize = within(screen.getByRole("group", { name: "حجم الخط" })).getByRole("status");

expect(speed).not.toHaveAccessibleName();
expect(speed).toHaveTextContent(`${new Intl.NumberFormat("ar").format(40)} بكسل/ثانية`);
expect(fontSize).not.toHaveAccessibleName();
expect(fontSize).toHaveTextContent(`${new Intl.NumberFormat("ar").format(16)} بكسل`);
});
});
Loading
Loading