test(ui): add KeyValueField + RevealToggle + ValidationHint coverage (29 cases) #616
142
canvas/src/components/ui/__tests__/KeyValueField.test.tsx
Normal file
142
canvas/src/components/ui/__tests__/KeyValueField.test.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for KeyValueField component.
|
||||
*
|
||||
* Covers: initial password type, onChange callback (including whitespace trim
|
||||
* on type), aria-label forwarding, disabled state, and auto-hide timer setup.
|
||||
*/
|
||||
import React from "react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { KeyValueField } from "../KeyValueField";
|
||||
|
||||
describe("KeyValueField — rendering", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders input with type=password by default (secret hidden)", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
expect(input.getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("passes custom aria-label to the input element", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} aria-label="API secret key" />);
|
||||
expect(screen.getByLabelText("API secret key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the input when disabled=true", () => {
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} disabled />);
|
||||
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with the current value", () => {
|
||||
render(<KeyValueField value="sk-test-key-123" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123");
|
||||
});
|
||||
|
||||
it("renders with the placeholder text", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
|
||||
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
|
||||
});
|
||||
|
||||
it("renders the RevealToggle child button", () => {
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// KeyValueField renders exactly one button (the RevealToggle)
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — onChange", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("calls onChange with the new value when user types", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } });
|
||||
expect(onChange).toHaveBeenCalledWith("new-value");
|
||||
});
|
||||
|
||||
it("trims leading whitespace when user types with leading space", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } });
|
||||
expect(onChange).toHaveBeenCalledWith("trimmed");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace when user types with trailing space", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } });
|
||||
expect(onChange).toHaveBeenCalledWith("trimmed");
|
||||
});
|
||||
|
||||
it("trims both sides when user types whitespace-surrounded value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } });
|
||||
expect(onChange).toHaveBeenCalledWith("both sides");
|
||||
});
|
||||
|
||||
it("does not modify value with no whitespace", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } });
|
||||
expect(onChange).toHaveBeenCalledWith("clean-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — auto-hide timer setup", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// No timer should be set initially (revealed=false by default)
|
||||
const callsBeforeInteraction = setTimeoutSpy.mock.calls.length;
|
||||
|
||||
// Simulate reveal (click the only button)
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// After reveal, a 30s timer should be set
|
||||
const timerCalls = setTimeoutSpy.mock.calls.filter(
|
||||
([, delay]) => delay === 30_000,
|
||||
);
|
||||
expect(timerCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("clears existing timer when a new toggle happens before auto-hide fires", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const timerObj = {}; // fake timer ID
|
||||
vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => {
|
||||
return timerObj;
|
||||
});
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
|
||||
// First toggle — reveal
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// Second toggle — hide (should clear the timer from first toggle)
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// clearTimeout was called with the timer object
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj);
|
||||
});
|
||||
|
||||
it("clears timer on unmount", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const { unmount } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
|
||||
// Toggle reveal to start the timer
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
unmount();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
68
canvas/src/components/ui/__tests__/RevealToggle.test.tsx
Normal file
68
canvas/src/components/ui/__tests__/RevealToggle.test.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for RevealToggle component.
|
||||
*
|
||||
* Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback,
|
||||
* aria-label (default + custom), title attribute.
|
||||
*/
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { RevealToggle } from "../RevealToggle";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RevealToggle", () => {
|
||||
it("renders as a button", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses default aria-label when not provided", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret");
|
||||
});
|
||||
|
||||
it("uses custom aria-label when provided", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
|
||||
});
|
||||
|
||||
it('title is "Hide value" when revealed', () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
|
||||
});
|
||||
|
||||
it('title is "Show value" when hidden', () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked (revealed=true → should hide)", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={true} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked (revealed=false → should show)", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={false} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the eye-open SVG (hide icon) when revealed=false", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
// The eye SVG contains a circle element; eye-off has a strikethrough line
|
||||
expect(btn.querySelector("circle")).toBeTruthy();
|
||||
expect(btn.querySelectorAll("line")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders the eye-off SVG (show icon) when revealed=true", () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
// EyeOffIcon has a line (strikethrough) through the eye
|
||||
expect(btn.querySelectorAll("line")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
49
canvas/src/components/ui/__tests__/ValidationHint.test.tsx
Normal file
49
canvas/src/components/ui/__tests__/ValidationHint.test.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ValidationHint component.
|
||||
*
|
||||
* Covers: null/neutral render, error state (red ⚠ + message), valid state
|
||||
* (green ✓ + "Valid format"), ARIA role="alert" on error.
|
||||
*/
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { ValidationHint } from "../ValidationHint";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ValidationHint", () => {
|
||||
it("renders nothing when error is null and showValid is false", () => {
|
||||
const { container } = render(<ValidationHint error={null} showValid={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when error is null and showValid is undefined", () => {
|
||||
const { container } = render(<ValidationHint error={null} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders error state with ⚠ icon and message", () => {
|
||||
render(<ValidationHint error="Key name must be UPPER_SNAKE_CASE" />);
|
||||
const el = screen.getByRole("alert");
|
||||
expect(el.textContent).toContain("⚠");
|
||||
expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE");
|
||||
});
|
||||
|
||||
it("renders valid state with ✓ and 'Valid format'", () => {
|
||||
render(<ValidationHint error={null} showValid />);
|
||||
const el = screen.getByText("Valid format");
|
||||
expect(el.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("prefers error over valid when both are set (error is not null)", () => {
|
||||
// ValidationHint checks error first; showValid is only rendered when error is falsy.
|
||||
render(<ValidationHint error="Some error" showValid />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText("Valid format")).toBeNull();
|
||||
});
|
||||
|
||||
it("error alert has role='alert' for screen readers", () => {
|
||||
render(<ValidationHint error="Invalid format" />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user