Files
Triple-C/app/src/components/notes/NotesPanel.test.tsx
T
shadowdaoandClaude Opus 5 436b6dd470 Send a lone CR through the newline transform, and say what pinned is
`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
2026-09-01 13:47:01 -07:00

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"));
});
});