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
36 changes: 35 additions & 1 deletion app/(tools)/color-converter/ColorConverterClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,45 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ColorConverterClient } from "./ColorConverterClient";
import { contrastRatio, parseColor } from "@/lib/color-utils";
import { useRegisterCommands } from "@/components/shared/CommandPalette";

const { push } = vi.hoisted(() => ({ push: vi.fn() }));

vi.mock("@/components/shared/CommandPalette", () => ({ useRegisterCommands: vi.fn() }));
vi.mock("@/components/shared/Toast", () => ({ useToast: () => ({ push: vi.fn() }) }));
vi.mock("@/components/shared/Toast", () => ({ useToast: () => ({ push }) }));
afterEach(cleanup);

describe("palette clipboard feedback", () => {
it.each([
["hex", "#ff0000"],
["rgb", "rgb(255, 0, 0)"],
["hsl", "hsl(0, 100%, 50%)"],
])("reports %s copy results with the correct value", async (format, expected) => {
push.mockClear();
const writeText = vi.fn().mockRejectedValue(new Error("Clipboard denied"));
const clipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
try {
render(<ColorConverterClient />);
fireEvent.change(screen.getByLabelText("Color input"), { target: { value: "#ff0000" } });
const commands = vi.mocked(useRegisterCommands).mock.calls.at(-1)![1];
const command = commands.find(({ id }) => id === `color:copy-${format}`)!;
await expect(command.run()).resolves.toBeUndefined();
expect(writeText).toHaveBeenCalledOnce();
expect(writeText).toHaveBeenCalledWith(expected);
expect(push).toHaveBeenCalledExactlyOnceWith("Could not copy — clipboard blocked", "error");
push.mockClear();
writeText.mockReset().mockResolvedValue(undefined);
await command.run();
expect(writeText).toHaveBeenCalledExactlyOnceWith(expected);
expect(push).toHaveBeenCalledExactlyOnceWith(`Copied ${format.toUpperCase()} to clipboard`, "success");
} finally {
if (clipboard) Object.defineProperty(navigator, "clipboard", clipboard);
else Reflect.deleteProperty(navigator, "clipboard");
}
});
});

function failingPair() {
render(<ColorConverterClient />);
const foreground = screen.getByLabelText("Text / Foreground");
Expand Down
24 changes: 18 additions & 6 deletions app/(tools)/color-converter/ColorConverterClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,12 @@ export function ColorConverterClient() {
title: "Copy current color as HEX",
run: async () => {
if (hexValue) {
await navigator.clipboard.writeText(hexValue);
push("Copied HEX to clipboard", "success");
try {
await navigator.clipboard.writeText(hexValue);
push("Copied HEX to clipboard", "success");
} catch {
push("Could not copy — clipboard blocked", "error");
}
}
},
},
Expand All @@ -173,8 +177,12 @@ export function ColorConverterClient() {
title: "Copy current color as RGB",
run: async () => {
if (rgbValue) {
await navigator.clipboard.writeText(rgbValue);
push("Copied RGB to clipboard", "success");
try {
await navigator.clipboard.writeText(rgbValue);
push("Copied RGB to clipboard", "success");
} catch {
push("Could not copy — clipboard blocked", "error");
}
}
},
},
Expand All @@ -184,8 +192,12 @@ export function ColorConverterClient() {
title: "Copy current color as HSL",
run: async () => {
if (hslValue) {
await navigator.clipboard.writeText(hslValue);
push("Copied HSL to clipboard", "success");
try {
await navigator.clipboard.writeText(hslValue);
push("Copied HSL to clipboard", "success");
} catch {
push("Could not copy — clipboard blocked", "error");
}
}
},
},
Expand Down
7 changes: 1 addition & 6 deletions app/(tools)/csv-formatter/components/StatsBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { formatBytes } from "@/lib/base64-utils";
import { byteLength, delimiterLabel, lineEndingLabel, DELIMITERS, type ParsedCsv } from "@/lib/csv-utils";

export function StatsBar({ parsed, input }: { parsed: ParsedCsv; input: string }) {
Expand Down Expand Up @@ -43,9 +44,3 @@ function StatCell({ label, value, sub }: { label: string; value: string; sub?: s
</div>
);
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
13 changes: 4 additions & 9 deletions app/(tools)/json-formatter/components/StatsBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { formatBytes } from "@/lib/base64-utils";
import { computeStats, gzipSize } from "@/lib/json-utils";

export function StatsBar({ input, output }: { input: string; output: string }) {
Expand All @@ -26,9 +27,9 @@ export function StatsBar({ input, output }: { input: string; output: string }) {
return (
<div className="card p-4 animate-fade-in">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
<StatCell label="Size" value={fmt(stats.bytes)} />
<StatCell label="Minified" value={`${fmt(stats.bytesMin)}`} sub={`-${savedPct}%`} />
<StatCell label="Gzipped" value={gzip === null ? "…" : fmt(gzip)} />
<StatCell label="Size" value={formatBytes(stats.bytes)} />
<StatCell label="Minified" value={`${formatBytes(stats.bytesMin)}`} sub={`-${savedPct}%`} />
<StatCell label="Gzipped" value={gzip === null ? "…" : formatBytes(gzip)} />
<StatCell label="Depth" value={String(stats.depth)} />
<StatCell label="Keys" value={String(stats.totalKeys)} sub={`${stats.objects} obj · ${stats.arrays} arr`} />
<StatCell
Expand Down Expand Up @@ -56,9 +57,3 @@ function StatCell({ label, value, sub }: { label: string; value: string; sub?: s
</div>
);
}

function fmt(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
229 changes: 228 additions & 1 deletion app/(tools)/regex-tester/RegexTesterClient.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "@/components/shared/Toast";
import { RegexTesterClient } from "./RegexTesterClient";
Expand Down Expand Up @@ -55,3 +55,230 @@ describe("RegexTesterClient", () => {
await waitFor(() => expect(mockWriteText).toHaveBeenCalled());
});
});

describe("RegexTesterClient match navigation", () => {
const previous = () => screen.getByRole("button", { name: "Previous match" });
const next = () => screen.getByRole("button", { name: "Next match" });
const status = () => screen.getByRole("status", { name: "Match navigation" });

function settleInput() {
act(() => vi.advanceTimersByTime(150));
}

function changeInput(label: string, value: string) {
fireEvent.change(screen.getByLabelText(label), { target: { value } });
settleInput();
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});

it("announces the initial position and distinguishes the active highlight", () => {
renderClient();
expect(status()).toHaveTextContent("Match 1 of 2");
expect(status()).toHaveAttribute("aria-live", "polite");
expect(status()).toHaveAttribute("aria-atomic", "true");
expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true");
expect(screen.getByLabelText("Match 2")).not.toHaveAttribute("aria-current");
expect(previous()).toBeEnabled();
expect(next()).toBeEnabled();
});

it("moves forward and backward, wrapping in both directions", () => {
renderClient();
fireEvent.click(next());
expect(status()).toHaveTextContent("Match 2 of 2");
expect(screen.getByLabelText("Match 2")).toHaveAttribute("aria-current", "true");
expect(screen.getByLabelText("Match 1")).not.toHaveAttribute("aria-current");
fireEvent.click(next());
expect(status()).toHaveTextContent("Match 1 of 2");
fireEvent.click(previous());
expect(status()).toHaveTextContent("Match 2 of 2");
fireEvent.click(previous());
expect(status()).toHaveTextContent("Match 1 of 2");
expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true");
});

it("keeps a single match selected in both directions", () => {
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {});
renderClient();
changeInput("Pattern", "^Released");
expect(scroll).not.toHaveBeenCalled();
fireEvent.click(next());
fireEvent.click(previous());
expect(status()).toHaveTextContent("Match 1 of 1");
expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true");
expect(scroll).toHaveBeenCalledTimes(2);
expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 1"));
});

it.each(["no matches", "empty pattern", "overlong text"])("disables navigation for %s", (scenario) => {
renderClient();
if (scenario === "no matches") changeInput("Pattern", "not-present");
else if (scenario === "empty pattern") changeInput("Pattern", "");
else changeInput("Test text", "a".repeat(100_001));
expect(previous()).toBeDisabled();
expect(next()).toBeDisabled();
expect(status()).toHaveTextContent("No matches");
expect(document.querySelector('[aria-current="true"]')).toBeNull();
});

it.each(["Pattern", "Test text", "flags"])("resets selection when %s changes", (field) => {
renderClient();
fireEvent.click(next());
expect(status()).toHaveTextContent("Match 2 of 2");
if (field === "flags") fireEvent.click(screen.getByRole("button", { name: "Ignore case" }));
else fireEvent.change(screen.getByLabelText(field), {
target: { value: field === "Pattern" ? "\\d{4}" : "2027-01-01 and 2028-02-02" },
});
expect(status()).toHaveTextContent("Updating matches");
expect(next()).toBeDisabled();
expect(previous()).toBeDisabled();
settleInput();
expect(status()).toHaveTextContent("Match 1 of 2");
expect(screen.getByLabelText("Match 1")).toHaveAttribute("aria-current", "true");
});

it("clears invalid results and restores navigation after correcting the pattern", () => {
renderClient();
fireEvent.click(next());
changeInput("Pattern", "(");
expect(screen.getByRole("alert")).toHaveTextContent("Invalid pattern");
expect(next()).toBeDisabled();
expect(previous()).toBeDisabled();
expect(status()).toHaveTextContent("No matches");
expect(document.querySelector('[aria-current="true"]')).toBeNull();
changeInput("Pattern", "\\d{4}");
expect(status()).toHaveTextContent("Match 1 of 2");
expect(next()).toBeEnabled();
expect(previous()).toBeEnabled();
});

it("keeps the index valid through batched edits and shrinking results", () => {
renderClient();
fireEvent.click(next());
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "(" } });
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "a" } });
fireEvent.change(screen.getByLabelText("Test text"), { target: { value: "aaa" } });
settleInput();
fireEvent.click(previous());
expect(status()).toHaveTextContent("Match 3 of 3");
changeInput("Test text", "a");
expect(status()).toHaveTextContent("Match 1 of 1");
changeInput("Test text", "b");
expect(status()).toHaveTextContent("No matches");
changeInput("Test text", "aa");
expect(status()).toHaveTextContent("Match 1 of 2");
});

it("navigates visible markers for zero-length matches, including the end of input", () => {
renderClient();
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "(?:)" } });
fireEvent.change(screen.getByLabelText("Test text"), { target: { value: "ab" } });
settleInput();
expect(status()).toHaveTextContent("Match 1 of 3");
fireEvent.click(next());
expect(status()).toHaveTextContent("Match 2 of 3");
expect(screen.getByLabelText("Match 2 (empty)")).toHaveAttribute("aria-current", "true");
fireEvent.click(previous());
fireEvent.click(previous());
expect(status()).toHaveTextContent("Match 3 of 3");
expect(screen.getByLabelText("Match 3 (empty)")).toHaveAttribute("aria-current", "true");
fireEvent.click(next());
expect(status()).toHaveTextContent("Match 1 of 3");
expect(screen.getByLabelText("Highlighted matches")).toHaveTextContent("ab");
});

it("navigates only the 1,000 displayed matches when results are truncated", () => {
renderClient();
const position = status();
const previousButton = previous();
const nextButton = next();
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "a" } });
fireEvent.change(screen.getByLabelText("Test text"), { target: { value: "a".repeat(1001) } });
settleInput();
expect(position).toHaveTextContent("Match 1 of 1000");
expect(screen.getByRole("heading", { name: "More than 1,000 matches, showing the first 1,000" })).toBeInTheDocument();
fireEvent.click(previousButton);
expect(position).toHaveTextContent("Match 1000 of 1000");
expect(screen.getByLabelText("Match 1000")).toHaveAttribute("aria-current", "true");
expect(screen.queryByLabelText("Match 1001")).not.toBeInTheDocument();
fireEvent.click(nextButton);
expect(position).toHaveTextContent("Match 1 of 1000");
});

it("scrolls the active highlight without stealing focus or reacting to replacement edits", () => {
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {});
renderClient();
scroll.mockClear();
next().focus();
fireEvent.click(next());
expect(next()).toHaveFocus();
expect(scroll).toHaveBeenLastCalledWith({ block: "nearest", inline: "nearest" });
expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2"));
fireEvent.click(next());
expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 1"));
previous().focus();
fireEvent.click(previous());
expect(previous()).toHaveFocus();
expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2"));
scroll.mockClear();
changeInput("Replace with", "updated");
expect(status()).toHaveTextContent("Match 2 of 2");
expect(scroll).not.toHaveBeenCalled();
});

it.each(["Pattern", "Test text", "flags"])("scrolls only on navigation, not on mount or a %s edit", (field) => {
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(() => {});
renderClient();
settleInput();
expect(scroll).not.toHaveBeenCalled();

fireEvent.click(next());
expect(scroll).toHaveBeenCalledTimes(1);
expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2"));
if (field === "flags") fireEvent.click(screen.getByRole("button", { name: "Ignore case" }));
else fireEvent.change(screen.getByLabelText(field), {
target: { value: field === "Pattern" ? "\\d{4}" : "2027-01-01 and 2028-02-02" },
});
expect(scroll).toHaveBeenCalledTimes(1);
settleInput();
expect(status()).toHaveTextContent("Match 1 of 2");
expect(scroll).toHaveBeenCalledTimes(1);

fireEvent.click(previous());
expect(scroll).toHaveBeenCalledTimes(2);
expect(scroll.mock.contexts.at(-1)).toBe(screen.getByLabelText("Match 2"));
});

it.each(["Pattern", "Test text"])("keeps match details and replacement mounted while %s is pending", (field) => {
renderClient();
fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "a" } });
changeInput("Test text", "a a a");
const details = screen.getByRole("table");
const replacement = screen.getByLabelText("Replace with");

fireEvent.change(screen.getByLabelText(field), {
target: { value: field === "Pattern" ? "a+" : "a a a a" },
});
expect(status()).toHaveTextContent("Updating matches");
expect(next()).toBeDisabled();
expect(previous()).toBeDisabled();
expect(document.querySelector('[aria-current="true"]')).toBeNull();
expect(screen.getByRole("table")).toBe(details);
expect(screen.getByLabelText("Replace with")).toBe(replacement);
expect(screen.getByRole("heading", { name: "3 matches" })).toBeInTheDocument();

settleInput();
expect(screen.getByRole("table")).toBe(details);
expect(screen.getByLabelText("Replace with")).toBe(replacement);
expect(status()).toHaveTextContent(field === "Pattern" ? "Match 1 of 3" : "Match 1 of 4");
});
});
Loading
Loading