test(FileEditor, ApprovalBanner): add 30-case FileEditor suite + fix ApprovalBanner mock isolation
FileEditor.test.tsx: - 30 cases: empty state, file header, dirty badge, download, save button (root-gated), Cmd+S, Tab indentation, readOnly gating, loading, success - Uses makeProps() factory to avoid React 19 + vi.fn() module-scope + defaultProps issue (prop values resolving to mock objects) - Uses Object.defineProperty for jsdom textarea selectionStart - Removes redundant badge-on-change test (covered by other cases) ApprovalBanner.test.tsx: - Fix mock isolation: afterEach uses vi.clearAllMocks() instead of mockRestore().beforeEach re-applies vi.spyOn factory so tests are resilient to vi.restoreAllMocks() calls from other files (aria-time-sensitive.test.tsx calls vi.restoreAllMocks() in afterEach) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
97628b6eaf
commit
287e95db02
@ -0,0 +1,283 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for FileEditor — the text editor pane in the Files tab.
|
||||
*
|
||||
* FileEditor is fully prop-driven (no stores, no API calls).
|
||||
* All props passed explicitly per-test to avoid defaultProps + vi.fn()
|
||||
* module-scope issues in React 19.
|
||||
*
|
||||
* Coverage:
|
||||
* - Empty state: no selected file → placeholder UI
|
||||
* - File header: filename and icon rendered
|
||||
* - Modified badge: shown when editContent ≠ fileContent
|
||||
* - Modified badge: hidden when content is clean
|
||||
* - Download button calls onDownload
|
||||
* - Save button disabled when not dirty
|
||||
* - Save button disabled when saving
|
||||
* - Save button shows "Saving..." text when saving
|
||||
* - Save button hidden when root ≠ /configs
|
||||
* - Save button visible when root === /configs
|
||||
* - Save button enabled when dirty and not saving
|
||||
* - Cmd+S triggers onSave
|
||||
* - Tab key inserts two spaces
|
||||
* - Textarea is readOnly when root ≠ /configs
|
||||
* - Textarea is writable when root === /configs
|
||||
* - Loading state shows "Loading..." text
|
||||
* - onChange updates editContent
|
||||
* - Success message displayed when success prop is set
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FileEditor } from "../FileEditor";
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
selectedFile: null as string | null,
|
||||
fileContent: "",
|
||||
editContent: "",
|
||||
setEditContent: vi.fn<(v: string) => void>(),
|
||||
loadingFile: false,
|
||||
saving: false,
|
||||
success: null as string | null,
|
||||
root: "/workspace",
|
||||
onSave: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Empty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — empty state", () => {
|
||||
it("shows placeholder when no file selected", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: null })} />);
|
||||
expect(screen.getByText("Select a file to edit")).toBeTruthy();
|
||||
expect(screen.getByText("📄")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT render textarea when no file selected", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: null })} />);
|
||||
expect(screen.queryByRole("textbox")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── File header ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — file header", () => {
|
||||
it("shows the selected filename in monospace", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "src/main.py" })} />);
|
||||
expect(screen.getByText("src/main.py")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the correct icon for a Python file", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "app.py" })} />);
|
||||
expect(screen.getByText("🐍")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the correct icon for a TypeScript file", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "index.ts" })} />);
|
||||
expect(screen.getByText("💠")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dirty state ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — dirty/modified state", () => {
|
||||
it("shows 'modified' badge when editContent differs from fileContent", () => {
|
||||
render(
|
||||
<FileEditor {...makeProps({ selectedFile: "cfg.yaml", fileContent: "original", editContent: "changed" })} />
|
||||
);
|
||||
expect(screen.getByText("modified")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT show 'modified' badge when content matches", () => {
|
||||
render(
|
||||
<FileEditor {...makeProps({ selectedFile: "cfg.yaml", fileContent: "same", editContent: "same" })} />
|
||||
);
|
||||
expect(screen.queryByText("modified")).toBeFalsy();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Download button ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — download", () => {
|
||||
it("renders a Download button with aria-label", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "data.csv" })} />);
|
||||
expect(screen.getByRole("button", { name: /download/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onDownload when Download button is clicked", () => {
|
||||
const onDownload = vi.fn();
|
||||
render(<FileEditor {...makeProps({ selectedFile: "report.pdf", onDownload })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /download/i }));
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Save button ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — save button", () => {
|
||||
it("renders a Save button when root is /configs", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "config.yaml" })} />);
|
||||
expect(screen.getByRole("button", { name: /save/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Save button is NOT rendered when root is /workspace", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/workspace", selectedFile: "script.sh" })} />);
|
||||
expect(screen.queryByRole("button", { name: /save/i })).toBeFalsy();
|
||||
});
|
||||
|
||||
it("Save button is NOT rendered when root is /files", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/files", selectedFile: "doc.md" })} />);
|
||||
expect(screen.queryByRole("button", { name: /save/i })).toBeFalsy();
|
||||
});
|
||||
|
||||
it("Save button is disabled when content is clean (not dirty)", () => {
|
||||
render(
|
||||
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=1" })} />
|
||||
);
|
||||
// Use exact match to avoid matching "Saving..." which also contains "save"
|
||||
const btn = screen.getByRole("button", { name: /^Save$/i });
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("Save button is enabled when dirty and not saving", () => {
|
||||
render(
|
||||
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=2" })} />
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /^Save$/i });
|
||||
expect(btn.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("Save button is disabled when saving is true", () => {
|
||||
render(
|
||||
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=2", saving: true })} />
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /saving/i });
|
||||
expect(btn.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("Save button shows 'Saving...' when saving", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", saving: true })} />);
|
||||
expect(screen.getByText("Saving...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Save button shows 'Save' when not saving", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", saving: false })} />);
|
||||
expect(screen.getByText("Save")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onSave when Save button is clicked", () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<FileEditor {...makeProps({ root: "/configs", selectedFile: "cfg.yaml", fileContent: "x=1", editContent: "x=2", onSave })} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard shortcuts ───────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — keyboard shortcuts", () => {
|
||||
it("Cmd+S triggers onSave in textarea", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", onSave })} />);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
fireEvent.keyDown(textarea, { key: "s", metaKey: true });
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Tab inserts two spaces at cursor position", () => {
|
||||
// Use a real state variable so the Tab handler reads the correct updated value.
|
||||
// jsdom's selectionStart on textarea is unreliable with fireEvent, so we control
|
||||
// the value via state and use a real setEditContent.
|
||||
let editContent = "hello";
|
||||
const setEditContent = vi.fn((v: string) => { editContent = v; });
|
||||
const { rerender } = render(
|
||||
<FileEditor {...makeProps({ selectedFile: "x.py", editContent, setEditContent })} />
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
// jsdom textarea selectionStart getter is read from the element's _value; force it.
|
||||
Object.defineProperty(textarea, "selectionStart", { value: 2, writable: true, configurable: true });
|
||||
Object.defineProperty(textarea, "selectionEnd", { value: 2, writable: true, configurable: true });
|
||||
fireEvent.keyDown(textarea, { key: "Tab" });
|
||||
// val = "hello", start=end=2 → "he" + " " + "llo" = "he llo"
|
||||
expect(setEditContent).toHaveBeenCalledWith("he llo");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Textarea ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — textarea", () => {
|
||||
it("renders textarea with the current editContent value", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "f.py", editContent: "hello world" })} />);
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe("hello world");
|
||||
});
|
||||
|
||||
it("calls setEditContent on change", () => {
|
||||
const setEditContent = vi.fn();
|
||||
render(<FileEditor {...makeProps({ selectedFile: "f.py", editContent: "", setEditContent })} />);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "new text" } });
|
||||
expect(setEditContent).toHaveBeenCalledWith("new text");
|
||||
});
|
||||
|
||||
it("textarea is readOnly when root is /workspace", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/workspace", selectedFile: "f.py" })} />);
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("textarea is readOnly when root is /files", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/files", selectedFile: "f.py" })} />);
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("textarea is writable when root is /configs", () => {
|
||||
render(<FileEditor {...makeProps({ root: "/configs", selectedFile: "f.py" })} />);
|
||||
expect((screen.getByRole("textbox") as HTMLTextAreaElement).readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Loading state ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — loading state", () => {
|
||||
it("shows 'Loading...' when loadingFile is true", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "big.py", loadingFile: true })} />);
|
||||
expect(screen.getByText("Loading...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides textarea when loadingFile is true", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "big.py", loadingFile: true })} />);
|
||||
expect(screen.queryByRole("textbox")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Success message ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("FileEditor — success message", () => {
|
||||
it("shows success message when success prop is set", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", success: "Saved!" })} />);
|
||||
expect(screen.getByText("Saved!")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("success message uses good colour class", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", success: "Done" })} />);
|
||||
const msg = screen.getByText("Done");
|
||||
expect(msg.className).toContain("text-good");
|
||||
});
|
||||
|
||||
it("does NOT render success element when success is null", () => {
|
||||
render(<FileEditor {...makeProps({ selectedFile: "cfg.yaml", success: null })} />);
|
||||
const header = screen.getByText("cfg.yaml").closest("div");
|
||||
const successEl = header?.querySelector('[class*="text-good"]');
|
||||
expect(successEl).toBeFalsy();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user