`toClaudePayload` matched `/\r?\n/`, so a bare CR that is not part of a CRLF went through verbatim — and a bare CR *submits* in a Claude prompt and *runs* the line in a shell, which is the terminator the function's own contract says it never appends. A `<textarea>` cannot produce one, but `load_in` returns whatever a hand-edited or externally written notes file holds, so the guarantee has to cover that rather than only what the editor can type. `Note.pinned` is persisted and sorted on, but nothing in the app sets it: there is no pin control and no indicator. The spec stated the ordering rule as though pinning existed and §8 did not list it, so the spec is amended to say `pinned` is reserved and inert in v1, and pinning is added to the out-of-scope list. No UI is added — a user-facing affordance does not belong in a fix wave. Also renamed NotesPanel.test.tsx's "deletes the selected note and falls back to another": `useNotes` is mocked in that file and the mocked list never changes, so the fallback was never exercised. A name that claims coverage which is absent is worse than an absent test, because it makes the gap invisible. The real assertion now lives against the real hook in NotesPanel.shared.test.tsx. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
113 lines
3.9 KiB
TypeScript
113 lines
3.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import NotesPanel from "./NotesPanel";
|
|
import type { Note } from "../../lib/types";
|
|
|
|
const saveNote = vi.fn(async () => true);
|
|
const deleteNote = vi.fn(async () => true);
|
|
const createNote = vi.fn();
|
|
let notes: Note[] = [];
|
|
let loading = false;
|
|
|
|
vi.mock("../../hooks/useNotes", () => ({
|
|
useNotes: () => ({
|
|
notes,
|
|
loading,
|
|
saveState: { status: "idle", error: null },
|
|
createNote,
|
|
saveNote,
|
|
deleteNote,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./SendToAgentButton", () => ({
|
|
default: ({ body }: { body: string }) => (
|
|
<button type="button" data-testid="send">{`send:${body}`}</button>
|
|
),
|
|
}));
|
|
|
|
const note = (over: Partial<Note> = {}): Note => ({
|
|
id: "n1",
|
|
title: "Deploy steps",
|
|
body: "one\ntwo",
|
|
pinned: false,
|
|
created_at: "2026-09-01T00:00:00Z",
|
|
updated_at: "2026-09-01T00:00:00Z",
|
|
...over,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
notes = [];
|
|
loading = false;
|
|
});
|
|
|
|
describe("NotesPanel", () => {
|
|
it("invites the user to start when there are no notes", () => {
|
|
render(<NotesPanel projectId="p1" />);
|
|
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("lists notes by title and selects the first", () => {
|
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
|
render(<NotesPanel projectId="p1" />);
|
|
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
|
|
});
|
|
|
|
it("shows an untitled note under a placeholder rather than a blank row", () => {
|
|
notes = [note({ title: "" })];
|
|
render(<NotesPanel projectId="p1" />);
|
|
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("switches the editor when another note is selected", () => {
|
|
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
|
render(<NotesPanel projectId="p1" />);
|
|
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
|
|
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
|
|
});
|
|
|
|
it("saves on blur, not on every keystroke", async () => {
|
|
notes = [note()];
|
|
render(<NotesPanel projectId="p1" />);
|
|
const body = screen.getByLabelText("Note body");
|
|
|
|
fireEvent.change(body, { target: { value: "edited" } });
|
|
expect(saveNote).not.toHaveBeenCalled();
|
|
|
|
fireEvent.blur(body);
|
|
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: "n1", body: "edited" }),
|
|
));
|
|
});
|
|
|
|
it("does not save on blur when nothing changed", async () => {
|
|
// Clicking through notes to read them must not write the file.
|
|
notes = [note()];
|
|
render(<NotesPanel projectId="p1" />);
|
|
fireEvent.blur(screen.getByLabelText("Note body"));
|
|
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
|
|
});
|
|
|
|
it("hands the live editor text to the send button, not the last saved copy", () => {
|
|
// Sending what is on screen is the whole contract: no transform on the way
|
|
// out except the newline substitution.
|
|
notes = [note()];
|
|
render(<NotesPanel projectId="p1" />);
|
|
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
|
|
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
|
});
|
|
|
|
it("asks the hook to delete the selected note", async () => {
|
|
// Only the call: `useNotes` is mocked here and the mocked list never
|
|
// changes, so nothing in this file can exercise what the panel selects
|
|
// afterwards. The fallback is covered against the real hook in
|
|
// NotesPanel.shared.test.tsx.
|
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
|
render(<NotesPanel projectId="p1" />);
|
|
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
|
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
|
});
|
|
});
|