; itemCount: number; itemData: unknown }) => {
+ const Row = children as unknown as React.ComponentType<{ index: number; style: React.CSSProperties; data: unknown }>
+ return (
+
+ {Array.from({ length: itemCount }).map((_, index) => (
+
+ ))}
+
+ )
+ },
+}))
+
+function makeNote(overrides: Partial = {}): Note {
+ return {
+ id: "note-1",
+ user_id: "user-1",
+ title: "Sample Note Title",
+ content: "Sample note content",
+ description: "Sample note description",
+ is_pinned: false,
+ is_archived: false,
+ is_trashed: false,
+ created_at: "2026-01-01T10:00:00.000Z",
+ updated_at: "2026-01-02T12:00:00.000Z",
+ tags: ["frontend", "react", "testing", "extra-tag"],
+ ...overrides,
+ } as Note
+}
+
+describe("VirtualNoteList", () => {
+ const defaultProps = {
+ height: 600,
+ selectedNote: null,
+ onSelectNote: jest.fn(),
+ onTagClick: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe("Empty & Null states", () => {
+ it("renders null when notes array is empty", () => {
+ const { container } = render(
+
+ )
+ expect(container.firstChild).toBeNull()
+ })
+
+ it("renders null when notes prop is null or undefined", () => {
+ const { container } = render(
+ // @ts-expect-error testing runtime fallback
+
+ )
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe("Virtualized rendering & Note items", () => {
+ it("renders notes using the virtual list component", () => {
+ const notes = [
+ makeNote({ id: "note-1", title: "Note 1" }),
+ makeNote({ id: "note-2", title: "Note 2" }),
+ ]
+
+ render()
+
+ expect(screen.getByText("Note 1")).toBeTruthy()
+ expect(screen.getByText("Note 2")).toBeTruthy()
+ })
+
+ it("renders skeleton placeholder when note is missing in itemData index", () => {
+ const CustomList = ({ children, itemData }: { children: React.ComponentType; itemData: Record }) => {
+ const Row = children as unknown as React.ComponentType<{ index: number; style: React.CSSProperties; data: unknown }>
+ return (
+
+
+
+ )
+ }
+
+ const { container } = render(
+
+ )
+
+ expect(container.querySelector(".animate-pulse")).toBeTruthy()
+ })
+ })
+
+ describe("Note text & tag fallbacks", () => {
+ it("renders default title and description fallbacks when fields are empty", () => {
+ const notes = [
+ makeNote({
+ id: "empty-note",
+ title: "",
+ // @ts-expect-error testing null description
+ description: null,
+ tags: [],
+ }),
+ ]
+
+ render()
+
+ expect(screen.getByText("Untitled Note")).toBeTruthy()
+ expect(screen.getByText("No additional text")).toBeTruthy()
+ })
+
+ it("slices tags to a maximum of 3 items", () => {
+ const notes = [
+ makeNote({
+ tags: ["tag1", "tag2", "tag3", "tag4", "tag5"],
+ }),
+ ]
+
+ render()
+
+ expect(screen.getByText("tag1")).toBeTruthy()
+ expect(screen.getByText("tag2")).toBeTruthy()
+ expect(screen.getByText("tag3")).toBeTruthy()
+ expect(screen.queryByText("tag4")).toBeNull()
+ expect(screen.queryByText("tag5")).toBeNull()
+ })
+
+ it("renders spacer when note has no tags", () => {
+ const notes = [makeNote({ tags: [] })]
+ const { container } = render(
+
+ )
+ expect(container.querySelector("span.h-5")).toBeTruthy()
+ })
+ })
+
+ describe("Selection State", () => {
+ it("renders selected styling when note matches selectedNote id", () => {
+ const note1 = makeNote({ id: "note-1", title: "Note 1" })
+ const note2 = makeNote({ id: "note-2", title: "Note 2" })
+ const notes = [note1, note2]
+
+ render(
+
+ )
+
+ const note1Button = screen.getByRole("button", { name: /Note 1/ })
+ const note2Button = screen.getByRole("button", { name: /Note 2/ })
+
+ expect(note1Button.getAttribute("aria-pressed")).toBe("true")
+ expect(note1Button.className).toContain("bg-accent")
+
+ expect(note2Button.getAttribute("aria-pressed")).toBe("false")
+ expect(note2Button.className).toContain("bg-card")
+ })
+ })
+
+ describe("Click and Keyboard Callbacks", () => {
+ it("calls onSelectNote when a note item is clicked", () => {
+ const note = makeNote({ id: "note-1", title: "Clickable Note" })
+ const onSelectNote = jest.fn()
+
+ render(
+
+ )
+
+ const noteBtn = screen.getByRole("button", { name: /Clickable Note/ })
+ fireEvent.click(noteBtn)
+
+ expect(onSelectNote).toHaveBeenCalledTimes(1)
+ expect(onSelectNote).toHaveBeenCalledWith(note)
+ })
+
+ it("calls onSelectNote when Enter or Space key is pressed on note item", () => {
+ const note = makeNote({ id: "note-1", title: "Keyboard Note" })
+ const onSelectNote = jest.fn()
+
+ render(
+
+ )
+
+ const noteBtn = screen.getByRole("button", { name: /Keyboard Note/ })
+
+ fireEvent.keyDown(noteBtn, { key: "Enter" })
+ expect(onSelectNote).toHaveBeenCalledTimes(1)
+ expect(onSelectNote).toHaveBeenCalledWith(note)
+
+ fireEvent.keyDown(noteBtn, { key: " " })
+ expect(onSelectNote).toHaveBeenCalledTimes(2)
+
+ // Unhandled keys should not trigger selection
+ fireEvent.keyDown(noteBtn, { key: "Tab" })
+ expect(onSelectNote).toHaveBeenCalledTimes(2)
+ })
+
+ it("does not call onSelectNote if keydown event originates from child element", () => {
+ const note = makeNote({ id: "note-1", title: "Parent Note" })
+ const onSelectNote = jest.fn()
+
+ render(
+
+ )
+
+ const titleHeader = screen.getByText("Parent Note")
+ fireEvent.keyDown(titleHeader, { key: "Enter", bubbles: true })
+
+ expect(onSelectNote).not.toHaveBeenCalled()
+ })
+
+ it("calls onTagClick when a tag is clicked", () => {
+ const note = makeNote({ id: "note-1", tags: ["react"] })
+ const onTagClick = jest.fn()
+ const onSelectNote = jest.fn()
+
+ render(
+
+ )
+
+ const tagElement = screen.getByText("react")
+ fireEvent.click(tagElement)
+
+ expect(onTagClick).toHaveBeenCalledWith("react")
+ expect(onSelectNote).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("Custom ListComponent & Window Resizing", () => {
+ it("passes custom height and virtualizer parameters to ListComponent", () => {
+ const customListSpy = jest.fn(({ children, height }: { children: React.ComponentType; height: number }) => {
+ const Row = children as unknown as React.ComponentType<{ index: number; style: React.CSSProperties; data: unknown }>
+ return (
+
+
+
+ )
+ })
+
+ const { rerender } = render(
+
+ )
+
+ expect(customListSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ height: 400,
+ itemCount: 1,
+ itemSize: 120,
+ width: "100%",
+ overscanCount: 5,
+ }),
+ undefined
+ )
+
+ // Simulate window resize changing height prop to 800
+ rerender(
+
+ )
+
+ expect(customListSpy).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ height: 800,
+ }),
+ undefined
+ )
+ })
+ })
+})
diff --git a/ui/web/tests/unit/components/executeEditorCommand.test.ts b/ui/web/tests/unit/components/executeEditorCommand.test.ts
new file mode 100644
index 00000000000..4a04c9af1f8
--- /dev/null
+++ b/ui/web/tests/unit/components/executeEditorCommand.test.ts
@@ -0,0 +1,455 @@
+import type { Editor } from "@tiptap/react"
+import { executeEditorCommand } from "@ui/web/components/executeEditorCommand"
+
+type MockChain = Record & {
+ focus: jest.Mock
+ unsetAllMarks: jest.Mock
+ clearNodes: jest.Mock
+ extendMarkRange: jest.Mock
+ setLink: jest.Mock
+ unsetLink: jest.Mock
+ setImage: jest.Mock
+ toggleHeading: jest.Mock
+ run: jest.Mock
+}
+
+function createMockEditor() {
+ const chain: MockChain = {
+ focus: jest.fn(),
+ unsetAllMarks: jest.fn(),
+ clearNodes: jest.fn(),
+ extendMarkRange: jest.fn(),
+ setLink: jest.fn(),
+ unsetLink: jest.fn(),
+ setImage: jest.fn(),
+ toggleHeading: jest.fn(),
+ run: jest.fn(),
+ }
+
+ chain.focus.mockReturnValue(chain)
+ chain.unsetAllMarks.mockReturnValue(chain)
+ chain.clearNodes.mockReturnValue(chain)
+ chain.extendMarkRange.mockReturnValue(chain)
+ chain.setLink.mockReturnValue(chain)
+ chain.unsetLink.mockReturnValue(chain)
+ chain.setImage.mockReturnValue(chain)
+ chain.toggleHeading.mockReturnValue(chain)
+
+ const undoMock = jest.fn()
+ const redoMock = jest.fn()
+
+ const editor = {
+ commands: {
+ undo: undoMock,
+ redo: redoMock,
+ },
+ chain: jest.fn().mockReturnValue(chain),
+ }
+
+ return {
+ editor: editor as unknown as Editor,
+ chain,
+ undoMock,
+ redoMock,
+ }
+}
+
+describe("executeEditorCommand", () => {
+ let onApplySelectionAsMarkdown: jest.Mock
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ onApplySelectionAsMarkdown = jest.fn()
+ })
+
+ describe("undo & redo commands", () => {
+ it("executes undo command via editor.commands.undo()", () => {
+ const { editor, undoMock, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "undo",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(undoMock).toHaveBeenCalledTimes(1)
+ expect(onApplySelectionAsMarkdown).not.toHaveBeenCalled()
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("executes redo command via editor.commands.redo()", () => {
+ const { editor, redoMock, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "redo",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(redoMock).toHaveBeenCalledTimes(1)
+ expect(onApplySelectionAsMarkdown).not.toHaveBeenCalled()
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("applySelectionAsMarkdown command", () => {
+ it("calls onApplySelectionAsMarkdown callback and does not touch editor chain or commands", () => {
+ const { editor, undoMock, redoMock, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "applySelectionAsMarkdown",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(onApplySelectionAsMarkdown).toHaveBeenCalledTimes(1)
+ expect(undoMock).not.toHaveBeenCalled()
+ expect(redoMock).not.toHaveBeenCalled()
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("clearFormatting command", () => {
+ it("chains focus -> unsetAllMarks -> clearNodes -> run", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "clearFormatting",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.focus).toHaveBeenCalledTimes(1)
+ expect(chain.unsetAllMarks).toHaveBeenCalledTimes(1)
+ expect(chain.clearNodes).toHaveBeenCalledTimes(1)
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ expect(onApplySelectionAsMarkdown).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("setLinkUrl command", () => {
+ it("sets link URL when a non-empty string argument is passed (trimming whitespace)", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "setLinkUrl",
+ args: [" https://example.com/test "],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.focus).toHaveBeenCalledTimes(1)
+ expect(chain.extendMarkRange).toHaveBeenCalledWith("link")
+ expect(chain.setLink).toHaveBeenCalledWith({ href: "https://example.com/test" })
+ expect(chain.unsetLink).not.toHaveBeenCalled()
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it("unsets link when an empty string argument is passed", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "setLinkUrl",
+ args: [" "],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.focus).toHaveBeenCalledTimes(1)
+ expect(chain.extendMarkRange).toHaveBeenCalledWith("link")
+ expect(chain.unsetLink).toHaveBeenCalledTimes(1)
+ expect(chain.setLink).not.toHaveBeenCalled()
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it("ignores command when non-string argument is provided", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "setLinkUrl",
+ args: [12345],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("ignores command when argument list is empty", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "setLinkUrl",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("insertImageUrl command", () => {
+ it("inserts image when a valid non-empty string URL argument is passed (trimming whitespace)", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "insertImageUrl",
+ args: [" https://example.com/image.png "],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.focus).toHaveBeenCalledTimes(1)
+ expect(chain.setImage).toHaveBeenCalledWith({ src: "https://example.com/image.png" })
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it("does nothing when an empty string URL is passed", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "insertImageUrl",
+ args: [" "],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.setImage).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("does nothing when first arg is not a string", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "insertImageUrl",
+ args: [null],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.setImage).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("does nothing when args is empty", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "insertImageUrl",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.setImage).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("toggleHeadingLevel command", () => {
+ it.each([1, 2, 3])("toggles heading level %i when passed as a number", (level) => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "toggleHeadingLevel",
+ args: [level],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.focus).toHaveBeenCalledTimes(1)
+ expect(chain.toggleHeading).toHaveBeenCalledWith({ level })
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it("toggles heading level when passed as a numeric string", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "toggleHeadingLevel",
+ args: ["2"],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.focus).toHaveBeenCalledTimes(1)
+ expect(chain.toggleHeading).toHaveBeenCalledWith({ level: 2 })
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it.each([0, 4, 5, -1])("ignores invalid heading level %i", (invalidLevel) => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "toggleHeadingLevel",
+ args: [invalidLevel],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.toggleHeading).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("ignores non-numeric or empty arguments for heading level", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "toggleHeadingLevel",
+ args: ["invalid"],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).not.toHaveBeenCalled()
+ expect(chain.toggleHeading).not.toHaveBeenCalled()
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+ })
+
+ describe("dynamic editor commands (formatting, lists, alignment, custom commands)", () => {
+ it.each([
+ "toggleBold",
+ "toggleItalic",
+ "toggleStrike",
+ "toggleCode",
+ "toggleBlockquote",
+ "toggleBulletList",
+ "toggleOrderedList",
+ "toggleTaskList",
+ ])("executes dynamic command '%s' with no arguments", (cmdName) => {
+ const { editor, chain } = createMockEditor()
+ const commandMock = jest.fn().mockReturnValue(chain)
+ chain[cmdName] = commandMock
+
+ executeEditorCommand({
+ editor,
+ command: cmdName,
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(commandMock).toHaveBeenCalledTimes(1)
+ expect(commandMock).toHaveBeenCalledWith()
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it("executes dynamic command with arguments (e.g. setTextAlign)", () => {
+ const { editor, chain } = createMockEditor()
+ const setTextAlignMock = jest.fn().mockReturnValue(chain)
+ chain.setTextAlign = setTextAlignMock
+
+ executeEditorCommand({
+ editor,
+ command: "setTextAlign",
+ args: ["center"],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(setTextAlignMock).toHaveBeenCalledTimes(1)
+ expect(setTextAlignMock).toHaveBeenCalledWith("center")
+ expect(chain.run).toHaveBeenCalledTimes(1)
+ })
+
+ it("does nothing if command is not a property on the editor chain", () => {
+ const { editor, chain } = createMockEditor()
+
+ executeEditorCommand({
+ editor,
+ command: "nonExistentCommand",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("does nothing if property on chain exists but is not a function", () => {
+ const { editor, chain } = createMockEditor()
+ chain.someStaticProperty = "not a function"
+
+ executeEditorCommand({
+ editor,
+ command: "someStaticProperty",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+
+ expect(editor.chain).toHaveBeenCalledTimes(1)
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("handles command function returning null without throwing", () => {
+ const { editor, chain } = createMockEditor()
+ chain.customCommand = jest.fn().mockReturnValue(null)
+
+ expect(() => {
+ executeEditorCommand({
+ editor,
+ command: "customCommand",
+ args: [1, 2],
+ onApplySelectionAsMarkdown,
+ })
+ }).not.toThrow()
+
+ expect(chain.customCommand).toHaveBeenCalledWith(1, 2)
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("handles command function returning an object without a run method without throwing", () => {
+ const { editor, chain } = createMockEditor()
+ chain.customCommand = jest.fn().mockReturnValue({ result: "ok" })
+
+ expect(() => {
+ executeEditorCommand({
+ editor,
+ command: "customCommand",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+ }).not.toThrow()
+
+ expect(chain.customCommand).toHaveBeenCalledTimes(1)
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+
+ it("handles command function returning an object with a non-function run property", () => {
+ const { editor, chain } = createMockEditor()
+ chain.customCommand = jest.fn().mockReturnValue({ run: "invalid" })
+
+ expect(() => {
+ executeEditorCommand({
+ editor,
+ command: "customCommand",
+ args: [],
+ onApplySelectionAsMarkdown,
+ })
+ }).not.toThrow()
+
+ expect(chain.customCommand).toHaveBeenCalledTimes(1)
+ expect(chain.run).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/ui/web/tests/unit/components/features/account/DeleteAccountDialog.test.tsx b/ui/web/tests/unit/components/features/account/DeleteAccountDialog.test.tsx
new file mode 100644
index 00000000000..06ae33e3015
--- /dev/null
+++ b/ui/web/tests/unit/components/features/account/DeleteAccountDialog.test.tsx
@@ -0,0 +1,106 @@
+import React from "react"
+import { fireEvent, render, screen, waitFor } from "@testing-library/react"
+
+import { DeleteAccountDialog } from "@/components/features/account/DeleteAccountDialog"
+
+describe("DeleteAccountDialog", () => {
+ const defaultProps = {
+ open: true,
+ onOpenChange: jest.fn(),
+ onConfirm: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("does not render dialog content when open is false", () => {
+ render()
+
+ expect(screen.queryByText("Delete my account")).toBeNull()
+ })
+
+ it("renders dialog title, description, tip message, checkbox label, and buttons when open is true", () => {
+ render()
+
+ expect(screen.getByRole("heading", { name: "Delete my account" })).toBeTruthy()
+ expect(
+ screen.getByText(
+ "This will permanently delete your account and all notes. Please export your notes before deleting if you need a copy."
+ )
+ ).toBeTruthy()
+ expect(
+ screen.getByText(
+ "Tip: use the Export option in the settings menu to download your notes before deleting your account."
+ )
+ ).toBeTruthy()
+ expect(
+ screen.getByText("I understand that my account and all notes will be permanently deleted.")
+ ).toBeTruthy()
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy()
+ expect(screen.getByRole("button", { name: "Delete account" })).toBeTruthy()
+ })
+
+ it("disables delete account button until acknowledgment checkbox is checked", () => {
+ render()
+
+ const deleteButton = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ const checkbox = screen.getByRole("checkbox", {
+ name: "I understand that my account and all notes will be permanently deleted.",
+ })
+
+ expect(deleteButton.disabled).toBe(true)
+
+ fireEvent.click(checkbox)
+ expect(deleteButton.disabled).toBe(false)
+
+ fireEvent.click(checkbox)
+ expect(deleteButton.disabled).toBe(true)
+ })
+
+ it("handles cancel button click and calls onOpenChange(false)", () => {
+ const onOpenChangeMock = jest.fn()
+
+ render()
+
+ const cancelButton = screen.getByRole("button", { name: "Cancel" })
+ fireEvent.click(cancelButton)
+
+ expect(onOpenChangeMock).toHaveBeenCalledWith(false)
+ })
+
+ it("executes onConfirm when enabled delete account button is clicked", async () => {
+ let resolveConfirm!: () => void
+ const confirmPromise = new Promise((resolve) => {
+ resolveConfirm = resolve
+ })
+ const onConfirmMock = jest.fn().mockReturnValue(confirmPromise)
+
+ render()
+
+ const checkbox = screen.getByRole("checkbox")
+ fireEvent.click(checkbox)
+
+ const deleteButton = screen.getByRole("button", { name: "Delete account" })
+ fireEvent.click(deleteButton)
+
+ expect(onConfirmMock).toHaveBeenCalledTimes(1)
+
+ resolveConfirm()
+
+ await waitFor(() => {
+ const buttonAfter = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ expect(buttonAfter.disabled).toBe(true)
+ })
+ })
+
+ it("disables cancel and action buttons and updates action text when loading is true", () => {
+ render()
+
+ const cancelButton = screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement
+ const deletingButton = screen.getByRole("button", { name: "Deleting..." }) as HTMLButtonElement
+
+ expect(cancelButton.disabled).toBe(true)
+ expect(deletingButton.disabled).toBe(true)
+ })
+})
diff --git a/ui/web/tests/unit/components/features/notes/MoreActionsMenu.test.tsx b/ui/web/tests/unit/components/features/notes/MoreActionsMenu.test.tsx
new file mode 100644
index 00000000000..9a34f7d4e3e
--- /dev/null
+++ b/ui/web/tests/unit/components/features/notes/MoreActionsMenu.test.tsx
@@ -0,0 +1,150 @@
+import * as React from "react"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { MoreActionsMenu } from "@/components/features/notes/MoreActionsMenu"
+import type { ExportableWordPressNote } from "@/components/features/wordpress/ExportToWordPressButton"
+
+jest.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
+
+ {children}
+
+ ),
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuItem: ({ children, onSelect, onClick, ...props }: { children?: React.ReactNode; onSelect?: (e: { preventDefault: () => void }) => void; onClick?: (e: React.MouseEvent) => void; className?: string }) => (
+
+ ),
+ DropdownMenuSeparator: () =>
,
+}))
+
+jest.mock("@/components/features/notes/ShareNoteDialog", () => ({
+ ShareNoteDialog: ({ noteId, open }: { noteId: string; open: boolean }) => (
+ open ? Share Note Dialog
: null
+ ),
+}))
+
+jest.mock("@/components/features/notes/RagIndexPanel", () => ({
+ RagIndexPanel: ({ noteId, variant, onMenuClose }: { noteId: string; variant: string; onMenuClose?: () => void }) => (
+
+
+
+ ),
+}))
+
+jest.mock("@/components/features/wordpress/ExportToWordPressButton", () => ({
+ ExportToWordPressButton: ({ onRequestExport, getNote }: { onRequestExport: (note: ExportableWordPressNote) => void; getNote: () => ExportableWordPressNote | null }) => (
+
+ ),
+}))
+
+jest.mock("@/components/features/wordpress/WordPressExportDialog", () => ({
+ WordPressExportDialog: ({ open, note }: { open: boolean; note: ExportableWordPressNote }) => (
+ open ? {note.title}
: null
+ ),
+}))
+
+describe("MoreActionsMenu", () => {
+ const sampleExportNote: ExportableWordPressNote = {
+ id: "note-1",
+ title: "Test Note",
+ description: "Test Content
",
+ tags: ["tech"],
+ }
+
+ const defaultProps = {
+ noteId: "note-1",
+ wordpressConfigured: false,
+ getExportNote: () => sampleExportNote,
+ }
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("renders trigger button and default menu items", () => {
+ render()
+
+ const triggerBtn = screen.getByRole("button", { name: "More actions" })
+ expect(triggerBtn).toBeTruthy()
+
+ expect(screen.getByText("Share note")).toBeTruthy()
+ expect(screen.getByTestId("mock-rag-index-panel")).toBeTruthy()
+ expect(screen.queryByTestId("wordpress-export-trigger")).toBeNull()
+ expect(screen.queryByText("Delete note")).toBeNull()
+ })
+
+ it("opens ShareNoteDialog when Share note item is selected", () => {
+ render()
+
+ const shareItem = screen.getByText("Share note")
+ fireEvent.click(shareItem)
+
+ expect(screen.getByTestId("share-note-dialog")).toBeTruthy()
+ expect(screen.getByTestId("share-note-dialog").getAttribute("data-note-id")).toBe("note-1")
+ })
+
+ it("renders WordPress export item when wordpressConfigured is true and opens export dialog on click", () => {
+ render(
+
+ )
+
+ const wpExportBtn = screen.getByTestId("wordpress-export-trigger")
+ expect(wpExportBtn).toBeTruthy()
+
+ fireEvent.click(wpExportBtn)
+
+ expect(screen.getByTestId("wordpress-export-dialog")).toBeTruthy()
+ expect(screen.getByText("Test Note")).toBeTruthy()
+ })
+
+ it("renders Delete note item when onDelete callback is provided and invokes it on click", () => {
+ const onDelete = jest.fn()
+ render()
+
+ const deleteItem = screen.getByText("Delete note")
+ expect(deleteItem).toBeTruthy()
+
+ fireEvent.click(deleteItem)
+
+ expect(onDelete).toHaveBeenCalledTimes(1)
+ })
+
+ it("supports closing menu via RAG panel callback", () => {
+ render()
+
+ const menu = screen.getByTestId("dropdown-menu")
+ const closeBtn = screen.getByTestId("close-menu-from-rag")
+
+ fireEvent.click(closeBtn)
+
+ expect(menu.getAttribute("data-open")).toBe("false")
+ })
+})
diff --git a/ui/web/tests/unit/components/features/notes/Sidebar.test.tsx b/ui/web/tests/unit/components/features/notes/Sidebar.test.tsx
new file mode 100644
index 00000000000..ab20ad39162
--- /dev/null
+++ b/ui/web/tests/unit/components/features/notes/Sidebar.test.tsx
@@ -0,0 +1,249 @@
+import * as React from "react"
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import { Sidebar } from "@/components/features/notes/Sidebar"
+import type { User } from "@supabase/supabase-js"
+
+const mockSetTheme = jest.fn()
+
+jest.mock("next-themes", () => ({
+ useTheme: () => ({
+ theme: "light",
+ setTheme: mockSetTheme,
+ resolvedTheme: "light",
+ }),
+}))
+
+const mockUser = {
+ id: "user-123",
+ email: "alex.dev@example.com",
+ app_metadata: {},
+ user_metadata: {},
+ aud: "authenticated",
+ created_at: "2026-01-01T00:00:00Z",
+} as User
+
+describe("Sidebar", () => {
+ const defaultProps = {
+ user: mockUser,
+ notesDisplayed: 5,
+ notesTotal: 20,
+ pendingCount: 0,
+ failedCount: 0,
+ isOffline: false,
+ selectionMode: false,
+ selectedCount: 0,
+ bulkDeleting: false,
+ onExitSelectionMode: jest.fn(),
+ onSelectAll: jest.fn(),
+ onBulkDelete: jest.fn(),
+ filterByTag: null,
+ onClearTagFilter: jest.fn(),
+ onOpenSearch: jest.fn(),
+ onOpenSettings: jest.fn(),
+ onCreateNote: jest.fn(),
+ onSignOut: jest.fn(),
+ children: Notes List Content
,
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe("Brand & Theme Header", () => {
+ it("renders EverFreeNote title and theme toggle", () => {
+ render()
+
+ const titleHeading = screen.getByRole("heading", { level: 1 })
+ expect(titleHeading.textContent).toBe("EverFreeNote")
+ expect(screen.getByRole("button", { name: "Toggle theme" })).toBeTruthy()
+ })
+
+ it("handles theme toggle click", async () => {
+ render()
+
+ const themeBtn = screen.getByRole("button", { name: "Toggle theme" })
+ fireEvent.click(themeBtn)
+
+ await waitFor(() => {
+ expect(mockSetTheme).toHaveBeenCalledWith("dark")
+ })
+ })
+ })
+
+ describe("Sync Status Indicator", () => {
+ it("renders Synchronized dot when online and counts are zero", () => {
+ const { container } = render()
+
+ const statusOutput = container.querySelector('output[aria-label="Synchronized"]')
+ expect(statusOutput).toBeTruthy()
+ expect(statusOutput?.className).toContain("bg-emerald-500")
+ })
+
+ it("renders Offline mode status dot when isOffline is true", () => {
+ const { container } = render()
+
+ const statusOutput = container.querySelector('output[aria-label="Offline mode"]')
+ expect(statusOutput).toBeTruthy()
+ expect(statusOutput?.className).toContain("bg-amber-500")
+ })
+
+ it("renders Syncing count status dot when pendingCount > 0", () => {
+ const { container } = render()
+
+ const statusOutput = container.querySelector('output[aria-label="Syncing: 3"]')
+ expect(statusOutput).toBeTruthy()
+ expect(statusOutput?.className).toContain("bg-muted-foreground")
+ })
+
+ it("renders Sync failed count status dot when failedCount > 0", () => {
+ const { container } = render()
+
+ const statusOutput = container.querySelector('output[aria-label="Sync failed: 2"]')
+ expect(statusOutput).toBeTruthy()
+ expect(statusOutput?.className).toContain("bg-destructive")
+ })
+ })
+
+ describe("Search & Navigation Actions", () => {
+ it("triggers onOpenSearch when search trigger is clicked", () => {
+ const onOpenSearch = jest.fn()
+ render()
+
+ const searchTrigger = screen.getByTestId("sidebar-search-trigger")
+ fireEvent.click(searchTrigger)
+
+ expect(onOpenSearch).toHaveBeenCalledTimes(1)
+ })
+
+ it("triggers onCreateNote when New Note button is clicked", () => {
+ const onCreateNote = jest.fn()
+ render()
+
+ const newNoteBtn = screen.getByRole("button", { name: /New Note/i })
+ fireEvent.click(newNoteBtn)
+
+ expect(onCreateNote).toHaveBeenCalledTimes(1)
+ })
+
+ it("formats notes count text correctly", () => {
+ render()
+ expect(screen.getByText("7 of 15 notes")).toBeTruthy()
+ })
+
+ it("renders fallback text when notes count props are omitted", () => {
+ render()
+ expect(screen.getByText("- of unknown notes")).toBeTruthy()
+ })
+ })
+
+ describe("Selection Mode & Bulk Delete Confirmation", () => {
+ it("renders SelectionModeActions when selectionMode is true", () => {
+ render(
+
+ )
+
+ expect(screen.getByTestId("selection-mode-count").textContent).toBe("3")
+ })
+
+ it("triggers onSelectAll when select all button is clicked in selection mode", () => {
+ const onSelectAll = jest.fn()
+ render(
+
+ )
+
+ const selectAllBtn = screen.getByTestId("selection-mode-select-all")
+ fireEvent.click(selectAllBtn)
+
+ expect(onSelectAll).toHaveBeenCalledTimes(1)
+ })
+
+ it("opens BulkDeleteDialog when bulk delete button is clicked and confirms bulk delete", async () => {
+ const onBulkDelete = jest.fn()
+ render(
+
+ )
+
+ const deleteBtn = screen.getByTestId("selection-mode-delete")
+ fireEvent.click(deleteBtn)
+
+ // Dialog opens
+ expect(screen.getByTestId("bulk-delete-dialog")).toBeTruthy()
+ expect(screen.getByText(/This action will delete 2 notes\./i)).toBeTruthy()
+
+ const input = screen.getByTestId("bulk-delete-confirm-input")
+ fireEvent.change(input, { target: { value: "2" } })
+
+ const confirmBtn = screen.getByTestId("bulk-delete-confirm")
+ fireEvent.click(confirmBtn)
+
+ await waitFor(() => {
+ expect(onBulkDelete).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it("triggers onExitSelectionMode when cancel button is clicked", () => {
+ const onExitSelectionMode = jest.fn()
+ render(
+
+ )
+
+ const cancelBtn = screen.getByRole("button", { name: /Cancel/i })
+ fireEvent.click(cancelBtn)
+
+ expect(onExitSelectionMode).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe("Children & User Profile Section", () => {
+ it("renders children in notes list container", () => {
+ render()
+ expect(screen.getByTestId("notes-list-children")).toBeTruthy()
+ })
+
+ it("renders user initial avatar and email address", () => {
+ render()
+
+ expect(screen.getByText("A")).toBeTruthy()
+ expect(screen.getByText("alex.dev@example.com")).toBeTruthy()
+ })
+
+ it("triggers onOpenSettings when settings button is clicked", () => {
+ const onOpenSettings = jest.fn()
+ render()
+
+ const settingsBtn = screen.getByRole("button", { name: "Open settings page" })
+ fireEvent.click(settingsBtn)
+
+ expect(onOpenSettings).toHaveBeenCalledTimes(1)
+ })
+
+ it("triggers onSignOut when sign out button is clicked", () => {
+ const onSignOut = jest.fn()
+ render()
+
+ const signOutBtn = screen.getByRole("button", { name: "Sign out" })
+ fireEvent.click(signOutBtn)
+
+ expect(onSignOut).toHaveBeenCalledTimes(1)
+ })
+ })
+})
diff --git a/ui/web/tests/unit/components/features/public/PublicSharePageClient.test.tsx b/ui/web/tests/unit/components/features/public/PublicSharePageClient.test.tsx
new file mode 100644
index 00000000000..355e571a662
--- /dev/null
+++ b/ui/web/tests/unit/components/features/public/PublicSharePageClient.test.tsx
@@ -0,0 +1,159 @@
+import React from "react"
+import { render, screen, waitFor, act } from "@testing-library/react"
+
+import { PublicSharePageClient } from "@/components/features/public/PublicSharePageClient"
+import type { PublicNote } from "@core/services/publicNoteShare"
+
+const mockGetSearchParams = jest.fn()
+jest.mock("next/navigation", () => ({
+ useSearchParams: () => ({
+ get: mockGetSearchParams,
+ }),
+}))
+
+const mockSupabase = {}
+jest.mock("@ui/web/providers/SupabaseProvider", () => ({
+ useSupabase: () => ({ supabase: mockSupabase }),
+}))
+
+const mockGetPublicNoteByToken = jest.fn()
+jest.mock("@core/services/publicNoteShare", () => ({
+ PublicNoteShareService: jest.fn().mockImplementation(() => ({
+ getPublicNoteByToken: mockGetPublicNoteByToken,
+ })),
+}))
+
+jest.mock("@/components/theme-toggle", () => ({
+ ThemeToggle: () => ,
+}))
+
+const makePublicNote = (overrides: Partial = {}): PublicNote => ({
+ token: "valid-token-123",
+ title: "Shared Test Note",
+ description: "This is a public note markdown content.
",
+ tags: ["public", "test"],
+ created_at: "2026-04-28T10:00:00.000Z",
+ updated_at: "2026-04-28T12:00:00.000Z",
+ ...overrides,
+})
+
+describe("PublicSharePageClient", () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockGetSearchParams.mockReturnValue("valid-token-123")
+ })
+
+ it("shows loading state initially while note is fetching", () => {
+ mockGetPublicNoteByToken.mockImplementation(() => new Promise(() => undefined))
+
+ render()
+
+ expect(screen.getByRole("heading", { name: "Loading shared note" })).toBeTruthy()
+ expect(screen.getByText("One moment while the note opens.")).toBeTruthy()
+ expect(screen.getByRole("button", { name: "Toggle theme" })).toBeTruthy()
+ })
+
+ it("loads and displays the public note with title, tags, formatted date, and markdown content", async () => {
+ const note = makePublicNote()
+ mockGetPublicNoteByToken.mockResolvedValue(note)
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Shared Test Note" })).toBeTruthy()
+ })
+
+ expect(mockGetPublicNoteByToken).toHaveBeenCalledWith("valid-token-123")
+ expect(screen.getByText("public note")).toBeTruthy()
+ expect(screen.getByText("public")).toBeTruthy()
+ expect(screen.getByText("test")).toBeTruthy()
+ expect(screen.getByText(/Shared note/i)).toBeTruthy()
+ expect(screen.getByRole("button", { name: "Toggle theme" })).toBeTruthy()
+ })
+
+ it("handles missing token in search parameters as not-found state", async () => {
+ mockGetSearchParams.mockReturnValue("")
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Note not available" })).toBeTruthy()
+ })
+
+ expect(screen.getByText("This shared note link is missing, inactive, or no longer available.")).toBeTruthy()
+ expect(mockGetPublicNoteByToken).not.toHaveBeenCalled()
+ })
+
+ it("handles null search params token as not-found state", async () => {
+ mockGetSearchParams.mockReturnValue(null)
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Note not available" })).toBeTruthy()
+ })
+
+ expect(screen.getByText("This shared note link is missing, inactive, or no longer available.")).toBeTruthy()
+ expect(mockGetPublicNoteByToken).not.toHaveBeenCalled()
+ })
+
+ it("displays not-found message when getPublicNoteByToken returns null", async () => {
+ mockGetPublicNoteByToken.mockResolvedValue(null)
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Note not available" })).toBeTruthy()
+ })
+
+ expect(screen.getByText("This shared note link is missing, inactive, or no longer available.")).toBeTruthy()
+ })
+
+ it("displays error message when getPublicNoteByToken rejects with Error", async () => {
+ mockGetPublicNoteByToken.mockRejectedValue(new Error("Database connection lost"))
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Could not load note" })).toBeTruthy()
+ })
+
+ expect(screen.getByText("Database connection lost")).toBeTruthy()
+ })
+
+ it("displays fallback error message when getPublicNoteByToken rejects with non-Error object", async () => {
+ mockGetPublicNoteByToken.mockRejectedValue("Unknown rejection")
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "Could not load note" })).toBeTruthy()
+ })
+
+ expect(screen.getByText("Could not load this shared note.")).toBeTruthy()
+ })
+
+ it("cancels state updates cleanly when unmounted before request resolves", async () => {
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
+
+ let resolvePromise!: (value: PublicNote | null) => void
+ const pendingPromise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+
+ mockGetPublicNoteByToken.mockReturnValue(pendingPromise)
+
+ const { unmount } = render()
+
+ // Unmount before promise resolves
+ unmount()
+
+ // Resolve after unmount - should not trigger console.error warning
+ await act(async () => {
+ resolvePromise(makePublicNote())
+ })
+
+ expect(consoleErrorSpy).not.toHaveBeenCalled()
+ consoleErrorSpy.mockRestore()
+ })
+})
diff --git a/ui/web/tests/unit/components/features/search/AiSearchPresetSelector.test.tsx b/ui/web/tests/unit/components/features/search/AiSearchPresetSelector.test.tsx
new file mode 100644
index 00000000000..d6299af3ac9
--- /dev/null
+++ b/ui/web/tests/unit/components/features/search/AiSearchPresetSelector.test.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { AiSearchPresetSelector } from '@ui/web/components/features/search/AiSearchPresetSelector'
+import { DEFAULT_PRESET } from '@core/constants/aiSearch'
+
+describe('AiSearchPresetSelector', () => {
+ it('renders search precision toggle group with all preset options', () => {
+ const onChange = jest.fn()
+ render()
+
+ const group = screen.getByRole('group', { name: 'Search precision' })
+ expect(group).toBeTruthy()
+
+ const strictBtn = screen.getByRole('radio', { name: 'Strict' })
+ const neutralBtn = screen.getByRole('radio', { name: 'Neutral' })
+ const broadBtn = screen.getByRole('radio', { name: 'Broad' })
+
+ expect(strictBtn).toBeTruthy()
+ expect(neutralBtn).toBeTruthy()
+ expect(broadBtn).toBeTruthy()
+ })
+
+ it('highlights the active preset selection', () => {
+ const onChange = jest.fn()
+ const { rerender } = render()
+
+ const strictBtn = screen.getByRole('radio', { name: 'Strict' })
+ const neutralBtn = screen.getByRole('radio', { name: 'Neutral' })
+ const broadBtn = screen.getByRole('radio', { name: 'Broad' })
+
+ expect(strictBtn.getAttribute('data-state')).toBe('on')
+ expect(neutralBtn.getAttribute('data-state')).toBe('off')
+ expect(broadBtn.getAttribute('data-state')).toBe('off')
+
+ rerender()
+
+ expect(strictBtn.getAttribute('data-state')).toBe('off')
+ expect(neutralBtn.getAttribute('data-state')).toBe('off')
+ expect(broadBtn.getAttribute('data-state')).toBe('on')
+ })
+
+ it('correctly uses DEFAULT_PRESET as active selection when passed', () => {
+ const onChange = jest.fn()
+ render()
+
+ const neutralBtn = screen.getByRole('radio', { name: 'Neutral' })
+ expect(neutralBtn.getAttribute('data-state')).toBe('on')
+ })
+
+ it('triggers onChange callback with the selected preset when clicked', () => {
+ const onChange = jest.fn()
+ render()
+
+ const strictBtn = screen.getByRole('radio', { name: 'Strict' })
+ fireEvent.click(strictBtn)
+
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('strict')
+
+ const broadBtn = screen.getByRole('radio', { name: 'Broad' })
+ fireEvent.click(broadBtn)
+
+ expect(onChange).toHaveBeenCalledTimes(2)
+ expect(onChange).toHaveBeenLastCalledWith('broad')
+ })
+
+ it('does not trigger onChange when clicking the currently active preset (unchecking ignored)', () => {
+ const onChange = jest.fn()
+ render()
+
+ const neutralBtn = screen.getByRole('radio', { name: 'Neutral' })
+ fireEvent.click(neutralBtn)
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/ui/web/tests/unit/components/features/search/ChunkSearchItem.test.tsx b/ui/web/tests/unit/components/features/search/ChunkSearchItem.test.tsx
new file mode 100644
index 00000000000..7a6a24d7446
--- /dev/null
+++ b/ui/web/tests/unit/components/features/search/ChunkSearchItem.test.tsx
@@ -0,0 +1,158 @@
+import * as React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { ChunkSearchItem } from '@ui/web/components/features/search/ChunkSearchItem'
+import type { RagChunk } from '@core/types/ragSearch'
+
+function makeRagChunk(overrides: Partial = {}): RagChunk {
+ return {
+ noteId: 'note-100',
+ noteTitle: 'React Testing Principles',
+ noteTags: ['testing', 'react'],
+ chunkIndex: 0,
+ charOffset: 25,
+ bodyContent: 'Unit tests ensure logic correctness and prevent regressions.',
+ overlapPrefix: '',
+ content: 'Unit tests ensure logic correctness and prevent regressions.',
+ similarity: 0.85,
+ ...overrides,
+ }
+}
+
+describe('ChunkSearchItem', () => {
+ const defaultProps = {
+ onOpenInContext: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Rendering note title & content snippet', () => {
+ it('renders note title and body content correctly', () => {
+ const chunk = makeRagChunk({
+ noteTitle: 'Architecture Overview',
+ bodyContent: 'Clean architecture decouples UI from business domain.',
+ })
+
+ render()
+
+ const titleHeading = screen.getByRole('heading', { level: 3 })
+ expect(titleHeading.textContent).toBe('Architecture Overview')
+ expect(screen.getByText('Clean architecture decouples UI from business domain.')).toBeTruthy()
+ })
+
+ it('falls back to "Untitled" when noteTitle is empty string or falsy', () => {
+ const chunk = makeRagChunk({
+ noteTitle: '',
+ })
+
+ render()
+
+ const titleHeading = screen.getByRole('heading', { level: 3 })
+ expect(titleHeading.textContent).toBe('Untitled')
+ expect(screen.getByRole('button').getAttribute('aria-label')).toBe('Open fragment from "Untitled" in context')
+ })
+
+ it('falls back to content property when bodyContent is missing or null', () => {
+ const chunk = makeRagChunk({
+ // @ts-expect-error testing runtime fallback
+ bodyContent: null,
+ content: 'Fallback content snippet text.',
+ })
+
+ render()
+
+ expect(screen.getByText('Fallback content snippet text.')).toBeTruthy()
+ })
+ })
+
+ describe('Similarity score formatting & styling tiers', () => {
+ it('renders similarity score percentage and emerald styling for score >= 0.8', () => {
+ const chunk = makeRagChunk({ similarity: 0.92 })
+
+ const { container } = render()
+
+ expect(screen.getByText('92%')).toBeTruthy()
+ expect(screen.getByText('92%').className).toContain('text-emerald-400')
+ expect(container.querySelector('[role="listitem"]')?.className).toContain('border-l-emerald-500')
+ })
+
+ it('renders similarity score percentage and amber styling for score >= 0.65 and < 0.8', () => {
+ const chunk = makeRagChunk({ similarity: 0.72 })
+
+ const { container } = render()
+
+ expect(screen.getByText('72%')).toBeTruthy()
+ expect(screen.getByText('72%').className).toContain('text-amber-400')
+ expect(container.querySelector('[role="listitem"]')?.className).toContain('border-l-amber-500')
+ })
+
+ it('renders default muted styling for score < 0.65', () => {
+ const chunk = makeRagChunk({ similarity: 0.45 })
+
+ const { container } = render()
+
+ expect(screen.getByText('45%')).toBeTruthy()
+ expect(screen.getByText('45%').className).toContain('text-muted-foreground/60')
+ expect(container.querySelector('[role="listitem"]')?.className).toContain('border-l-border')
+ })
+ })
+
+ describe('Content snippet highlighting', () => {
+ it('highlights matching search terms in snippet', () => {
+ const chunk = makeRagChunk({
+ bodyContent: 'TypeScript interfaces provide strong type safety.',
+ })
+
+ render()
+
+ const marks = screen.getAllByRole('mark')
+ expect(marks).toHaveLength(2)
+ expect(marks[0].textContent).toBe('interfaces')
+ expect(marks[1].textContent).toBe('safety')
+ })
+ })
+
+ describe('Click and Keyboard Interaction Handlers', () => {
+ it('triggers onOpenInContext callback when item is clicked', () => {
+ const onOpenInContext = jest.fn()
+ const chunk = makeRagChunk({
+ noteId: 'note-456',
+ charOffset: 50,
+ bodyContent: 'Sample text for testing offset calculation.',
+ })
+
+ render()
+
+ const button = screen.getByRole('button')
+ fireEvent.click(button)
+
+ expect(onOpenInContext).toHaveBeenCalledTimes(1)
+ expect(onOpenInContext).toHaveBeenCalledWith('note-456', 50, expect.any(Number))
+ })
+
+ it('triggers onOpenInContext when Enter or Space key is pressed', () => {
+ const onOpenInContext = jest.fn()
+ const chunk = makeRagChunk({
+ noteId: 'note-789',
+ charOffset: 10,
+ bodyContent: 'Keyboard navigation test snippet.',
+ })
+
+ render()
+
+ const button = screen.getByRole('button')
+
+ fireEvent.keyDown(button, { key: 'Enter' })
+ expect(onOpenInContext).toHaveBeenCalledTimes(1)
+ expect(onOpenInContext).toHaveBeenCalledWith('note-789', 10, expect.any(Number))
+
+ fireEvent.keyDown(button, { key: ' ' })
+ expect(onOpenInContext).toHaveBeenCalledTimes(2)
+
+ // Other keys should not trigger callback
+ fireEvent.keyDown(button, { key: 'Tab' })
+ expect(onOpenInContext).toHaveBeenCalledTimes(2)
+ })
+ })
+})
diff --git a/ui/web/tests/unit/components/features/search/NoteSearchResults.test.tsx b/ui/web/tests/unit/components/features/search/NoteSearchResults.test.tsx
new file mode 100644
index 00000000000..8b17c12a9ff
--- /dev/null
+++ b/ui/web/tests/unit/components/features/search/NoteSearchResults.test.tsx
@@ -0,0 +1,263 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { NoteSearchResults } from '@ui/web/components/features/search/NoteSearchResults'
+import type { RagChunk, RagNoteGroup } from '@core/types/ragSearch'
+
+function makeChunk(overrides: Partial = {}): RagChunk {
+ return {
+ noteId: 'note-1',
+ noteTitle: 'First Note Title',
+ noteTags: ['react', 'testing'],
+ chunkIndex: 0,
+ charOffset: 10,
+ bodyContent: 'First chunk body content',
+ overlapPrefix: '',
+ content: 'First chunk body content',
+ similarity: 0.92,
+ ...overrides,
+ }
+}
+
+function makeGroup(overrides: Partial = {}): RagNoteGroup {
+ return {
+ noteId: 'note-1',
+ noteTitle: 'First Note Title',
+ noteTags: ['react', 'testing'],
+ topScore: 0.92,
+ chunks: [makeChunk()],
+ hiddenCount: 0,
+ ...overrides,
+ }
+}
+
+describe('NoteSearchResults', () => {
+ const mockOnOpenInContext = jest.fn()
+ const mockOnTagClick = jest.fn()
+ const mockOnToggleSelect = jest.fn()
+ const mockOnLoadMore = jest.fn()
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('Empty Search State', () => {
+ it('renders empty search state message when noteGroups array is empty', () => {
+ render(
+
+ )
+
+ expect(
+ screen.getByText(
+ /No results\. Lower the precision slider or use the/i
+ )
+ ).toBeTruthy()
+ expect(screen.queryByRole('list')).toBeNull()
+ })
+ })
+
+ describe('Search Results List & Metadata Display', () => {
+ it('renders list container with correct role and aria-label', () => {
+ const groups = [makeGroup()]
+
+ render(
+
+ )
+
+ const listContainer = screen.getByRole('list', { name: 'Note search results' })
+ expect(listContainer).toBeTruthy()
+ })
+
+ it('renders multiple note search items with titles, metadata scores, and tags', () => {
+ const groups: RagNoteGroup[] = [
+ makeGroup({
+ noteId: 'note-1',
+ noteTitle: 'React Integration Guide',
+ topScore: 0.95,
+ noteTags: ['frontend', 'react'],
+ chunks: [
+ makeChunk({
+ noteId: 'note-1',
+ noteTitle: 'React Integration Guide',
+ bodyContent: 'React component setup instructions',
+ similarity: 0.95,
+ }),
+ ],
+ }),
+ makeGroup({
+ noteId: 'note-2',
+ noteTitle: 'TypeScript System Docs',
+ topScore: 0.78,
+ noteTags: ['typescript'],
+ chunks: [
+ makeChunk({
+ noteId: 'note-2',
+ noteTitle: 'TypeScript System Docs',
+ bodyContent: 'Type definitions and compiler options',
+ similarity: 0.78,
+ }),
+ ],
+ }),
+ ]
+
+ render(
+
+ )
+
+ expect(screen.getByText('React Integration Guide')).toBeTruthy()
+ expect(screen.getByText('TypeScript System Docs')).toBeTruthy()
+ expect(screen.getByText('95%')).toBeTruthy()
+ expect(screen.getByText('78%')).toBeTruthy()
+ expect(screen.getByText('frontend')).toBeTruthy()
+ expect(screen.getByText('typescript')).toBeTruthy()
+ expect(screen.getByText('React component setup instructions')).toBeTruthy()
+ expect(screen.getByText('Type definitions and compiler options')).toBeTruthy()
+ })
+
+ it('invokes onOpenInContext callback when clicking open fragment on a result item', () => {
+ const groups = [makeGroup({ noteId: 'note-42' })]
+
+ render(
+
+ )
+
+ const openButton = screen.getByRole('button', { name: /Open top fragment/i })
+ fireEvent.click(openButton)
+
+ expect(mockOnOpenInContext).toHaveBeenCalledTimes(1)
+ expect(mockOnOpenInContext).toHaveBeenCalledWith('note-42', 10, expect.any(Number))
+ })
+
+ it('invokes onTagClick callback when clicking an interactive tag', () => {
+ const groups = [makeGroup({ noteTags: ['architecture'] })]
+
+ render(
+
+ )
+
+ const tagElement = screen.getByText('architecture')
+ fireEvent.click(tagElement)
+
+ expect(mockOnTagClick).toHaveBeenCalledTimes(1)
+ expect(mockOnTagClick).toHaveBeenCalledWith('architecture')
+ })
+ })
+
+ describe('Selection Mode & Callback', () => {
+ it('passes selectionMode and isSelected state to result items', () => {
+ const groups = [
+ makeGroup({ noteId: 'note-1', noteTitle: 'Note One' }),
+ makeGroup({ noteId: 'note-2', noteTitle: 'Note Two' }),
+ ]
+ const selectedIds = new Set(['note-1'])
+
+ render(
+
+ )
+
+ const checkboxes = screen.getAllByRole('checkbox')
+ expect(checkboxes).toHaveLength(2)
+ expect(checkboxes[0].getAttribute("aria-checked")).toBe("true")
+ expect(checkboxes[1].getAttribute("aria-checked")).toBe("false")
+ })
+
+ it('triggers onToggleSelect callback when toggling selection on an item', () => {
+ const groups = [makeGroup({ noteId: 'note-10' })]
+
+ render(
+
+ )
+
+ const checkbox = screen.getByRole('checkbox')
+ fireEvent.click(checkbox)
+
+ expect(mockOnToggleSelect).toHaveBeenCalledTimes(1)
+ expect(mockOnToggleSelect).toHaveBeenCalledWith('note-10')
+ })
+ })
+
+ describe('Pagination & Load More State', () => {
+ it('renders Load More button when hasMore is true and invokes onLoadMore on click', () => {
+ const groups = [makeGroup()]
+
+ render(
+
+ )
+
+ const loadMoreButton = screen.getByRole('button', { name: /Load more\.\.\./i })
+ expect(loadMoreButton).toBeTruthy()
+
+ fireEvent.click(loadMoreButton)
+ expect(mockOnLoadMore).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders loading spinner when loadingMore is true and hides Load More button', () => {
+ const groups = [makeGroup()]
+
+ const { container } = render(
+
+ )
+
+ expect(screen.queryByRole('button', { name: /Load more\.\.\./i })).toBeNull()
+ const spinner = container.querySelector('.animate-spin')
+ expect(spinner).toBeTruthy()
+ })
+
+ it('does not render Load More button or spinner when hasMore is false and loadingMore is false', () => {
+ const groups = [makeGroup()]
+
+ const { container } = render(
+
+ )
+
+ expect(screen.queryByRole('button', { name: /Load more\.\.\./i })).toBeNull()
+ expect(container.querySelector('.animate-spin')).toBeNull()
+ })
+ })
+})
diff --git a/ui/web/tests/unit/components/features/settings/DeleteAccountPanel.test.tsx b/ui/web/tests/unit/components/features/settings/DeleteAccountPanel.test.tsx
new file mode 100644
index 00000000000..24fc1cabd03
--- /dev/null
+++ b/ui/web/tests/unit/components/features/settings/DeleteAccountPanel.test.tsx
@@ -0,0 +1,151 @@
+import React from "react"
+import { fireEvent, render, screen, waitFor } from "@testing-library/react"
+
+import { DeleteAccountPanel } from "@/components/features/settings/DeleteAccountPanel"
+
+describe("DeleteAccountPanel", () => {
+ const defaultProps = {
+ email: "user@example.com",
+ onConfirm: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("renders email, warning header, permanent action warning text, and checkbox label", () => {
+ render()
+
+ expect(screen.getByText("user@example.com")).toBeTruthy()
+ expect(screen.getByText("Permanent action")).toBeTruthy()
+ expect(
+ screen.getByText(
+ "This will permanently delete your account and all notes. Export your notes before deleting the account if you need a copy."
+ )
+ ).toBeTruthy()
+ expect(
+ screen.getByText("I understand that my account and all notes will be permanently deleted.")
+ ).toBeTruthy()
+ })
+
+ it("renders fallback text when email is missing or null", () => {
+ render()
+
+ expect(screen.getByText("No email available")).toBeTruthy()
+ })
+
+ it("keeps the delete button disabled until the acknowledgment checkbox is checked", () => {
+ render()
+
+ const deleteButton = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ const checkbox = screen.getByRole("checkbox", {
+ name: "I understand that my account and all notes will be permanently deleted.",
+ })
+
+ expect(deleteButton.disabled).toBe(true)
+
+ fireEvent.click(checkbox)
+ expect(deleteButton.disabled).toBe(false)
+
+ fireEvent.click(checkbox)
+ expect(deleteButton.disabled).toBe(true)
+ })
+
+ it("reflects external loading prop on delete button state and label", () => {
+ const { rerender } = render()
+
+ const checkbox = screen.getByRole("checkbox")
+ fireEvent.click(checkbox)
+
+ const deleteButton = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ expect(deleteButton.disabled).toBe(false)
+
+ rerender()
+
+ const loadingButton = screen.getByRole("button", { name: "Deleting..." }) as HTMLButtonElement
+ expect(loadingButton.disabled).toBe(true)
+ })
+
+ it("executes delete confirmation on button click and unchecks acknowledgment on success", async () => {
+ let resolveDelete!: () => void
+ const deletePromise = new Promise((resolve) => {
+ resolveDelete = resolve
+ })
+ const onConfirmMock = jest.fn().mockReturnValue(deletePromise)
+
+ render()
+
+ const checkbox = screen.getByRole("checkbox")
+ fireEvent.click(checkbox)
+
+ const deleteButton = screen.getByRole("button", { name: "Delete account" })
+ fireEvent.click(deleteButton)
+
+ expect(onConfirmMock).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole("button", { name: "Deleting..." })).toBeTruthy()
+
+ resolveDelete()
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Delete account" })).toBeTruthy()
+ })
+
+ const buttonAfter = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ expect(buttonAfter.disabled).toBe(true)
+ })
+
+ it("displays error message when onConfirm rejects with an Error object", async () => {
+ const onConfirmMock = jest.fn().mockRejectedValue(new Error("Server error during account deletion"))
+
+ render()
+
+ const checkbox = screen.getByRole("checkbox")
+ fireEvent.click(checkbox)
+
+ fireEvent.click(screen.getByRole("button", { name: "Delete account" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Server error during account deletion")).toBeTruthy()
+ })
+ const deleteBtnError = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ expect(deleteBtnError).toBeTruthy()
+ expect(deleteBtnError.disabled).toBe(false)
+ })
+
+ it("displays fallback error message when onConfirm rejects with a non-Error value", async () => {
+ const onConfirmMock = jest.fn().mockRejectedValue("unexpected error")
+
+ render()
+
+ const checkbox = screen.getByRole("checkbox")
+ fireEvent.click(checkbox)
+
+ fireEvent.click(screen.getByRole("button", { name: "Delete account" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Failed to delete account. Please try again.")).toBeTruthy()
+ })
+ const deleteBtnFallback = screen.getByRole("button", { name: "Delete account" }) as HTMLButtonElement
+ expect(deleteBtnFallback).toBeTruthy()
+ expect(deleteBtnFallback.disabled).toBe(false)
+ })
+
+ it("clears displayed error message when checkbox state changes", async () => {
+ const onConfirmMock = jest.fn().mockRejectedValue(new Error("Temporary deletion error"))
+
+ render()
+
+ const checkbox = screen.getByRole("checkbox")
+ fireEvent.click(checkbox)
+
+ fireEvent.click(screen.getByRole("button", { name: "Delete account" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Temporary deletion error")).toBeTruthy()
+ })
+
+ // Unchecking checkbox clears error
+ fireEvent.click(checkbox)
+ expect(screen.queryByText("Temporary deletion error")).toBeNull()
+ })
+})
diff --git a/ui/web/tests/unit/components/features/wordpress/ExportToWordPressButton.test.tsx b/ui/web/tests/unit/components/features/wordpress/ExportToWordPressButton.test.tsx
new file mode 100644
index 00000000000..253eac2e26a
--- /dev/null
+++ b/ui/web/tests/unit/components/features/wordpress/ExportToWordPressButton.test.tsx
@@ -0,0 +1,151 @@
+import * as React from "react"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { ExportToWordPressButton, type ExportableWordPressNote } from "@ui/web/components/features/wordpress/ExportToWordPressButton"
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
+
+describe("ExportToWordPressButton", () => {
+ const sampleNote: ExportableWordPressNote = {
+ id: "wp-note-1",
+ title: "WordPress Note Title",
+ description: "Note Body Description
",
+ tags: ["wordpress", "blog"],
+ }
+
+ const defaultProps = {
+ getNote: jest.fn(() => sampleNote),
+ onRequestExport: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe("Button Trigger Variant", () => {
+ it("renders default button with 'Export to WP' label", () => {
+ render()
+
+ const btn = screen.getByRole("button", { name: /Export to WP/i }) as HTMLButtonElement
+ expect(btn).toBeTruthy()
+ expect(btn.disabled).toBe(false)
+ })
+
+ it("renders custom label and custom variant when provided", () => {
+ render(
+
+ )
+
+ const btn = screen.getByRole("button", { name: "Publish to Blog" })
+ expect(btn).toBeTruthy()
+ expect(btn.className).toContain("bg-secondary")
+ })
+
+ it("triggers onRequestExport with note data when clicked", () => {
+ const onRequestExport = jest.fn()
+ const getNote = jest.fn(() => sampleNote)
+
+ render(
+
+ )
+
+ const btn = screen.getByRole("button", { name: /Export to WP/i })
+ fireEvent.click(btn)
+
+ expect(getNote).toHaveBeenCalledTimes(1)
+ expect(onRequestExport).toHaveBeenCalledTimes(1)
+ expect(onRequestExport).toHaveBeenCalledWith(sampleNote)
+ })
+
+ it("does not call onRequestExport if getNote returns null or note without id", () => {
+ const onRequestExport = jest.fn()
+ const getNoteNull = jest.fn(() => null)
+
+ const { rerender } = render(
+
+ )
+
+ const btn = screen.getByRole("button", { name: /Export to WP/i })
+ fireEvent.click(btn)
+
+ expect(getNoteNull).toHaveBeenCalledTimes(1)
+ expect(onRequestExport).not.toHaveBeenCalled()
+
+ const getNoteNoId = jest.fn(() => ({ title: "No ID Note" } as unknown as ExportableWordPressNote))
+
+ rerender(
+
+ )
+
+ fireEvent.click(btn)
+
+ expect(getNoteNoId).toHaveBeenCalledTimes(1)
+ expect(onRequestExport).not.toHaveBeenCalled()
+ })
+
+ it("disables the button when disabled prop is true", () => {
+ render()
+
+ const btn = screen.getByRole("button", { name: /Export to WP/i }) as HTMLButtonElement
+ expect(btn.disabled).toBe(true)
+ })
+ })
+
+ describe("Menu Item Trigger Variant", () => {
+ it("renders as DropdownMenuItem inside DropdownMenuContent and triggers export on select", () => {
+ const onRequestExport = jest.fn()
+ const getNote = jest.fn(() => sampleNote)
+
+ render(
+
+ Open
+
+
+
+
+ )
+
+ const menuItem = screen.getByRole("menuitem", { name: "Export from Menu" })
+ expect(menuItem).toBeTruthy()
+
+ fireEvent.click(menuItem)
+
+ expect(getNote).toHaveBeenCalledTimes(1)
+ expect(onRequestExport).toHaveBeenCalledWith(sampleNote)
+ })
+
+ it("disables menu item when disabled prop is true", () => {
+ render(
+
+ Open
+
+
+
+
+ )
+
+ const menuItem = screen.getByRole("menuitem", { name: "Export to WP" })
+ expect(menuItem.getAttribute("data-disabled")).not.toBeNull()
+ })
+ })
+})
diff --git a/ui/web/tests/unit/components/theme-toggle.test.tsx b/ui/web/tests/unit/components/theme-toggle.test.tsx
new file mode 100644
index 00000000000..8d955bc68c9
--- /dev/null
+++ b/ui/web/tests/unit/components/theme-toggle.test.tsx
@@ -0,0 +1,102 @@
+import React from "react"
+import { fireEvent, render, screen } from "@testing-library/react"
+import { useTheme } from "next-themes"
+
+import { ThemeToggle } from "@/components/theme-toggle"
+
+const mockSetTheme = jest.fn()
+
+jest.mock("next-themes", () => ({
+ useTheme: jest.fn(),
+}))
+
+describe("ThemeToggle", () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(useTheme).mockReturnValue({
+ theme: "light",
+ setTheme: mockSetTheme,
+ resolvedTheme: "light",
+ themes: ["light", "dark", "system"],
+ systemTheme: "light",
+ })
+ })
+
+ it("renders light mode theme toggle button with accessible label and Moon icon", () => {
+ render()
+
+ const button = screen.getByRole("button", { name: /toggle theme/i })
+ expect(button).toBeTruthy()
+ expect(button.getAttribute("title")).toBe("Switch to dark mode")
+ })
+
+ it("switches theme from light to dark on click", () => {
+ render()
+
+ const button = screen.getByRole("button", { name: /toggle theme/i })
+ fireEvent.click(button)
+
+ expect(mockSetTheme).toHaveBeenCalledWith("dark")
+ })
+
+ it("renders dark mode theme toggle with Sun icon and switches from dark to light on click", () => {
+ jest.mocked(useTheme).mockReturnValue({
+ theme: "dark",
+ setTheme: mockSetTheme,
+ resolvedTheme: "dark",
+ themes: ["light", "dark", "system"],
+ systemTheme: "dark",
+ })
+
+ render()
+
+ const button = screen.getByRole("button", { name: /toggle theme/i })
+ expect(button.getAttribute("title")).toBe("Switch to light mode")
+
+ fireEvent.click(button)
+ expect(mockSetTheme).toHaveBeenCalledWith("light")
+ })
+
+ it("handles system theme when resolvedTheme is dark", () => {
+ jest.mocked(useTheme).mockReturnValue({
+ theme: "system",
+ setTheme: mockSetTheme,
+ resolvedTheme: "dark",
+ themes: ["light", "dark", "system"],
+ systemTheme: "dark",
+ })
+
+ render()
+
+ const button = screen.getByRole("button", { name: /toggle theme/i })
+ expect(button.getAttribute("title")).toBe("Switch to light mode")
+
+ fireEvent.click(button)
+ expect(mockSetTheme).toHaveBeenCalledWith("light")
+ })
+
+ it("handles system theme when resolvedTheme is light", () => {
+ jest.mocked(useTheme).mockReturnValue({
+ theme: "system",
+ setTheme: mockSetTheme,
+ resolvedTheme: "light",
+ themes: ["light", "dark", "system"],
+ systemTheme: "light",
+ })
+
+ render()
+
+ const button = screen.getByRole("button", { name: /toggle theme/i })
+ expect(button.getAttribute("title")).toBe("Switch to dark mode")
+
+ fireEvent.click(button)
+ expect(mockSetTheme).toHaveBeenCalledWith("dark")
+ })
+
+ it("renders theme toggle button with toggle theme aria-label", () => {
+ render()
+
+ const button = screen.getByRole("button", { name: "Toggle theme" })
+ expect(button).toBeTruthy()
+ })
+})
diff --git a/ui/web/tests/unit/hooks/use-mobile.test.tsx b/ui/web/tests/unit/hooks/use-mobile.test.tsx
new file mode 100644
index 00000000000..9a566120bcd
--- /dev/null
+++ b/ui/web/tests/unit/hooks/use-mobile.test.tsx
@@ -0,0 +1,131 @@
+import { renderHook, act } from "@testing-library/react"
+import { useIsMobile } from "@ui/web/hooks/use-mobile"
+
+function setupMatchMedia(initialMatches = false) {
+ const listeners = new Set<(e: Event) => void>()
+
+ const mediaQueryList = {
+ matches: initialMatches,
+ media: "(max-width: 767px)",
+ onchange: null,
+ addEventListener: jest.fn((event: string, callback: (e: Event) => void) => {
+ if (event === "change") {
+ listeners.add(callback)
+ }
+ }),
+ removeEventListener: jest.fn((event: string, callback: (e: Event) => void) => {
+ if (event === "change") {
+ listeners.delete(callback)
+ }
+ }),
+ dispatchEvent: jest.fn(),
+ } as unknown as MediaQueryList
+
+ const matchMediaMock = jest.fn((query: string) => {
+ return {
+ ...mediaQueryList,
+ media: query,
+ }
+ })
+
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ configurable: true,
+ value: matchMediaMock,
+ })
+
+ const triggerChange = () => {
+ listeners.forEach((listener) => listener(new Event("change")))
+ }
+
+ return {
+ mediaQueryList,
+ matchMediaMock,
+ listeners,
+ triggerChange,
+ }
+}
+
+describe("useIsMobile", () => {
+ const originalInnerWidth = window.innerWidth
+
+ afterEach(() => {
+ Object.defineProperty(window, "innerWidth", {
+ writable: true,
+ configurable: true,
+ value: originalInnerWidth,
+ })
+ jest.clearAllMocks()
+ })
+
+ it("returns true when innerWidth is below 768px", () => {
+ setupMatchMedia(true)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 500 })
+
+ const { result } = renderHook(() => useIsMobile())
+
+ expect(result.current).toBe(true)
+ })
+
+ it("returns false when innerWidth is 768px or greater", () => {
+ setupMatchMedia(false)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 })
+
+ const { result } = renderHook(() => useIsMobile())
+
+ expect(result.current).toBe(false)
+ })
+
+ it("registers matchMedia listener for max-width: 767px", () => {
+ const { matchMediaMock, mediaQueryList } = setupMatchMedia(false)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 })
+
+ renderHook(() => useIsMobile())
+
+ expect(matchMediaMock).toHaveBeenCalledWith("(max-width: 767px)")
+ expect(mediaQueryList.addEventListener).toHaveBeenCalledWith("change", expect.any(Function))
+ })
+
+ it("updates mobile state when screen resizes and matchMedia change event triggers", () => {
+ const { triggerChange } = setupMatchMedia(false)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 })
+
+ const { result } = renderHook(() => useIsMobile())
+ expect(result.current).toBe(false)
+
+ // Simulate screen resize to mobile width
+ act(() => {
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 600 })
+ triggerChange()
+ })
+
+ expect(result.current).toBe(true)
+ })
+
+ it("removes matchMedia listener on unmount", () => {
+ const { mediaQueryList } = setupMatchMedia(false)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 1024 })
+
+ const { unmount } = renderHook(() => useIsMobile())
+
+ expect(mediaQueryList.addEventListener).toHaveBeenCalledWith("change", expect.any(Function))
+
+ unmount()
+
+ expect(mediaQueryList.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function))
+ })
+
+ it("handles exact breakpoint boundary at 767px and 768px correctly", () => {
+ setupMatchMedia(true)
+
+ // 767px is mobile (< 768)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 767 })
+ const { result: mobileResult } = renderHook(() => useIsMobile())
+ expect(mobileResult.current).toBe(true)
+
+ // 768px is desktop (not < 768)
+ Object.defineProperty(window, "innerWidth", { writable: true, configurable: true, value: 768 })
+ const { result: desktopResult } = renderHook(() => useIsMobile())
+ expect(desktopResult.current).toBe(false)
+ })
+})
diff --git a/ui/web/tests/unit/hooks/use-toast.test.tsx b/ui/web/tests/unit/hooks/use-toast.test.tsx
new file mode 100644
index 00000000000..bf677812a9f
--- /dev/null
+++ b/ui/web/tests/unit/hooks/use-toast.test.tsx
@@ -0,0 +1,310 @@
+import { act, renderHook } from '@testing-library/react'
+import { reducer, toast, useToast } from '@ui/web/hooks/use-toast'
+
+describe('use-toast', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ // Clear any lingering toasts and pending timers
+ act(() => {
+ toast({ title: 'Cleanup' }).dismiss()
+ jest.runAllTimers()
+ })
+ jest.useRealTimers()
+ })
+
+ describe('reducer', () => {
+ it('handles ADD_TOAST and respects TOAST_LIMIT of 1', () => {
+ const initialState = { toasts: [] }
+ const toast1 = { id: '1', title: 'First Toast' }
+ const toast2 = { id: '2', title: 'Second Toast' }
+
+ const state1 = reducer(initialState, { type: 'ADD_TOAST', toast: toast1 })
+ expect(state1.toasts).toEqual([toast1])
+
+ const state2 = reducer(state1, { type: 'ADD_TOAST', toast: toast2 })
+ expect(state2.toasts).toEqual([toast2])
+ })
+
+ it('handles UPDATE_TOAST for existing and non-existing toasts', () => {
+ const initialState = {
+ toasts: [
+ { id: '1', title: 'Original Title', description: 'Original Desc' },
+ ],
+ }
+
+ // Update matching toast
+ const updatedState = reducer(initialState, {
+ type: 'UPDATE_TOAST',
+ toast: { id: '1', title: 'Updated Title' },
+ })
+ expect(updatedState.toasts).toEqual([
+ { id: '1', title: 'Updated Title', description: 'Original Desc' },
+ ])
+
+ // Update non-matching toast ID
+ const noChangeState = reducer(initialState, {
+ type: 'UPDATE_TOAST',
+ toast: { id: '999', title: 'Non-existent' },
+ })
+ expect(noChangeState.toasts).toEqual(initialState.toasts)
+ })
+
+ it('handles DISMISS_TOAST with specific toastId', () => {
+ const initialState = {
+ toasts: [{ id: '1', title: 'Toast 1', open: true }],
+ }
+
+ const state = reducer(initialState, {
+ type: 'DISMISS_TOAST',
+ toastId: '1',
+ })
+
+ expect(state.toasts[0].open).toBe(false)
+ })
+
+ it('handles DISMISS_TOAST without toastId (dismiss all)', () => {
+ const initialState = {
+ toasts: [{ id: '1', title: 'Toast 1', open: true }],
+ }
+
+ const state = reducer(initialState, {
+ type: 'DISMISS_TOAST',
+ toastId: undefined,
+ })
+
+ expect(state.toasts.every((t) => t.open === false)).toBe(true)
+ })
+
+ it('handles REMOVE_TOAST with specific toastId', () => {
+ const initialState = {
+ toasts: [{ id: '1', title: 'Toast 1' }],
+ }
+
+ const state = reducer(initialState, {
+ type: 'REMOVE_TOAST',
+ toastId: '1',
+ })
+
+ expect(state.toasts).toEqual([])
+ })
+
+ it('handles REMOVE_TOAST without toastId (remove all)', () => {
+ const initialState = {
+ toasts: [{ id: '1', title: 'Toast 1' }],
+ }
+
+ const state = reducer(initialState, {
+ type: 'REMOVE_TOAST',
+ toastId: undefined,
+ })
+
+ expect(state.toasts).toEqual([])
+ })
+ })
+
+ describe('toast helper function', () => {
+ it('creates a toast, returns controls, and sets open: true', () => {
+ const { result } = renderHook(() => useToast())
+
+ let toastRef: ReturnType | undefined
+ act(() => {
+ toastRef = toast({
+ title: 'New Toast',
+ description: 'Toast Description',
+ })
+ })
+
+ expect(toastRef).toBeDefined()
+ expect(toastRef?.id).toBeDefined()
+ expect(result.current.toasts).toHaveLength(1)
+ expect(result.current.toasts[0]).toMatchObject({
+ id: toastRef?.id,
+ title: 'New Toast',
+ description: 'Toast Description',
+ open: true,
+ })
+ })
+
+ it('allows updating toast content via the returned update function', () => {
+ const { result } = renderHook(() => useToast())
+
+ let toastRef: ReturnType | undefined
+ act(() => {
+ toastRef = toast({ title: 'Initial Title' })
+ })
+
+ act(() => {
+ toastRef?.update({ id: toastRef.id, title: 'Updated Title' })
+ })
+
+ expect(result.current.toasts[0].title).toBe('Updated Title')
+ })
+
+ it('allows dismissing toast via the returned dismiss function', () => {
+ const { result } = renderHook(() => useToast())
+
+ let toastRef: ReturnType | undefined
+ act(() => {
+ toastRef = toast({ title: 'To Be Dismissed' })
+ })
+
+ expect(result.current.toasts[0].open).toBe(true)
+
+ act(() => {
+ toastRef?.dismiss()
+ })
+
+ expect(result.current.toasts[0].open).toBe(false)
+ })
+
+ it('triggers dismiss when onOpenChange is called with false', () => {
+ const { result } = renderHook(() => useToast())
+
+ act(() => {
+ toast({ title: 'OpenChange Toast' })
+ })
+
+ const currentToast = result.current.toasts[0]
+ expect(currentToast.open).toBe(true)
+
+ act(() => {
+ currentToast.onOpenChange?.(false)
+ })
+
+ expect(result.current.toasts[0].open).toBe(false)
+ })
+
+ it('does not trigger dismiss when onOpenChange is called with true', () => {
+ const { result } = renderHook(() => useToast())
+
+ act(() => {
+ toast({ title: 'OpenChange Toast' })
+ })
+
+ const currentToast = result.current.toasts[0]
+
+ act(() => {
+ currentToast.onOpenChange?.(true)
+ })
+
+ expect(result.current.toasts[0].open).toBe(true)
+ })
+ })
+
+ describe('useToast hook', () => {
+ it('provides toast state and helper methods', () => {
+ const { result } = renderHook(() => useToast())
+
+ expect(result.current.toasts).toBeDefined()
+ expect(typeof result.current.toast).toBe('function')
+ expect(typeof result.current.dismiss).toBe('function')
+ })
+
+ it('dismisses a specific toast using hook dismiss(id)', () => {
+ const { result } = renderHook(() => useToast())
+
+ let createdId: string | undefined
+ act(() => {
+ const t = result.current.toast({ title: 'Toast 1' })
+ createdId = t.id
+ })
+
+ expect(result.current.toasts[0].open).toBe(true)
+
+ act(() => {
+ result.current.dismiss(createdId)
+ })
+
+ expect(result.current.toasts[0].open).toBe(false)
+ })
+
+ it('dismisses all toasts when hook dismiss() is called without id', () => {
+ const { result } = renderHook(() => useToast())
+
+ act(() => {
+ result.current.toast({ title: 'Toast 1' })
+ })
+
+ expect(result.current.toasts[0].open).toBe(true)
+
+ act(() => {
+ result.current.dismiss()
+ })
+
+ expect(result.current.toasts[0].open).toBe(false)
+ })
+
+ it('removes toast from state after TOAST_REMOVE_DELAY (1000000ms)', () => {
+ const { result } = renderHook(() => useToast())
+
+ act(() => {
+ result.current.toast({ title: 'Temporary Toast' })
+ })
+
+ expect(result.current.toasts).toHaveLength(1)
+
+ act(() => {
+ result.current.dismiss()
+ })
+
+ expect(result.current.toasts[0].open).toBe(false)
+ expect(result.current.toasts).toHaveLength(1)
+
+ // Fast-forward removal delay
+ act(() => {
+ jest.advanceTimersByTime(1000000)
+ })
+
+ expect(result.current.toasts).toHaveLength(0)
+ })
+
+ it('prevents duplicate removal timers when dismiss is called twice', () => {
+ const { result } = renderHook(() => useToast())
+
+ let createdId: string | undefined
+ act(() => {
+ const t = result.current.toast({ title: 'Double Dismiss' })
+ createdId = t.id
+ })
+
+ act(() => {
+ result.current.dismiss(createdId)
+ result.current.dismiss(createdId)
+ })
+
+ expect(jest.getTimerCount()).toBe(1)
+ expect(result.current.toasts[0].open).toBe(false)
+
+ act(() => {
+ jest.advanceTimersByTime(1000000)
+ })
+
+ expect(result.current.toasts).toHaveLength(0)
+ })
+
+ it('subscribes and unsubscribes listeners cleanly on mount and unmount', () => {
+ const hook1 = renderHook(() => useToast())
+ const hook2 = renderHook(() => useToast())
+
+ act(() => {
+ toast({ title: 'Shared Toast' })
+ })
+
+ expect(hook1.result.current.toasts).toHaveLength(1)
+ expect(hook2.result.current.toasts).toHaveLength(1)
+
+ // Unmount hook1
+ hook1.unmount()
+
+ act(() => {
+ toast({ title: 'New Toast' })
+ })
+
+ // hook2 receives update, hook1 is unmounted
+ expect(hook2.result.current.toasts[0].title).toBe('New Toast')
+ })
+ })
+})
diff --git a/ui/web/tests/unit/hooks/useNoteData.test.tsx b/ui/web/tests/unit/hooks/useNoteData.test.tsx
new file mode 100644
index 00000000000..884ffe8e828
--- /dev/null
+++ b/ui/web/tests/unit/hooks/useNoteData.test.tsx
@@ -0,0 +1,277 @@
+import { renderHook } from '@testing-library/react'
+import type { NoteViewModel, SearchResult } from '@core/types/domain'
+import type { CachedNote } from '@core/types/offline'
+import { useNoteData } from '@ui/web/hooks/useNoteData'
+import type { useNotesQuery } from '@ui/web/hooks/useNotesQuery'
+import type { useNoteSearch } from '@ui/web/hooks/useNoteSearch'
+
+type AggregatedFtsData = NonNullable['aggregatedFtsData']>
+
+const makeNote = (id: string, overrides: Partial = {}): NoteViewModel => ({
+ id,
+ title: `Title ${id}`,
+ description: `Desc ${id}`,
+ tags: ['tag1'],
+ user_id: 'user-1',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+})
+
+const makeCachedNote = (id: string, overrides: Partial = {}): CachedNote => ({
+ id,
+ title: `Cached Title ${id}`,
+ description: `Cached Desc ${id}`,
+ tags: ['cached'],
+ status: 'pending',
+ updatedAt: '2026-01-02T00:00:00Z',
+ ...overrides,
+})
+
+const createMockNotesQuery = (
+ notes: NoteViewModel[] = [],
+ totalCount?: number
+) => {
+ return {
+ data: notes.length || totalCount !== undefined
+ ? {
+ pages: [
+ {
+ notes,
+ totalCount: totalCount ?? notes.length,
+ hasMore: false,
+ nextCursor: undefined,
+ },
+ ],
+ pageParams: [0],
+ }
+ : undefined,
+ isSuccess: true,
+ isError: false,
+ isLoading: false,
+ } as unknown as ReturnType
+}
+
+describe('useNoteData', () => {
+ it('computes base note list, notesById map, and counts correctly when no overlay is present', () => {
+ const note1 = makeNote('1')
+ const note2 = makeNote('2')
+ const notesQuery = createMockNotesQuery([note1, note2], 100)
+ const selectedNoteIds = new Set(['1'])
+
+ const { result } = renderHook(() =>
+ useNoteData({
+ notesQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: undefined,
+ selectedNoteIds,
+ })
+ )
+
+ expect(result.current.notes).toEqual([note1, note2])
+ expect(result.current.notesById.get('1')).toEqual(note1)
+ expect(result.current.notesById.get('2')).toEqual(note2)
+ expect(result.current.notesById.size).toBe(2)
+ expect(result.current.totalNotes).toBe(100)
+ expect(result.current.notesDisplayed).toBe(2)
+ expect(result.current.notesTotal).toBe(100)
+ expect(result.current.selectedCount).toBe(1)
+ expect(result.current.mergedFtsData).toBeUndefined()
+ })
+
+ it('applies offline overlay (modifications, additions, and deletions)', () => {
+ const note1 = makeNote('1', { title: 'Server Title 1' })
+ const note2 = makeNote('2', { title: 'Server Title 2' })
+ const notesQuery = createMockNotesQuery([note1, note2])
+
+ const offlineOverlay: CachedNote[] = [
+ makeCachedNote('1', { title: 'Overlay Title 1', updatedAt: '2026-01-05T00:00:00Z' }),
+ makeCachedNote('3', { title: 'New Offline Note', updatedAt: '2026-01-06T00:00:00Z' }),
+ makeCachedNote('2', { deleted: true }),
+ ]
+
+ const { result } = renderHook(() =>
+ useNoteData({
+ notesQuery,
+ offlineOverlay,
+ aggregatedFtsData: undefined,
+ selectedNoteIds: new Set(),
+ })
+ )
+
+ // note2 should be excluded (deleted), note1 modified, note3 added
+ expect(result.current.notesById.has('2')).toBe(false)
+ expect(result.current.notesById.get('1')?.title).toBe('Overlay Title 1')
+ expect(result.current.notesById.get('3')?.title).toBe('New Offline Note')
+ expect(result.current.notesDisplayed).toBe(2)
+ })
+
+ it('falls back to notes.length for totalNotes when totalCount is missing or pages are empty', () => {
+ const note1 = makeNote('1')
+ const queryWithoutTotalCount = {
+ data: {
+ pages: [{ notes: [note1], totalCount: undefined as unknown as number, hasMore: false }],
+ pageParams: [0],
+ },
+ } as unknown as ReturnType
+
+ const { result: result1 } = renderHook(() =>
+ useNoteData({
+ notesQuery: queryWithoutTotalCount,
+ offlineOverlay: [],
+ aggregatedFtsData: undefined,
+ selectedNoteIds: new Set(),
+ })
+ )
+
+ expect(result1.current.totalNotes).toBe(1)
+ expect(result1.current.notesTotal).toBe(1)
+
+ const emptyQuery = createMockNotesQuery([])
+ const { result: result2 } = renderHook(() =>
+ useNoteData({
+ notesQuery: emptyQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: undefined,
+ selectedNoteIds: new Set(),
+ })
+ )
+
+ expect(result2.current.totalNotes).toBe(0)
+ expect(result2.current.notesTotal).toBe(0)
+ })
+
+ it('resolves search results by merging latest fields from notesById', () => {
+ const note1 = makeNote('1', { title: 'Updated Title', updated_at: '2026-01-10T00:00:00Z' })
+ const notesQuery = createMockNotesQuery([note1])
+
+ const { result } = renderHook(() =>
+ useNoteData({
+ notesQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: undefined,
+ selectedNoteIds: new Set(),
+ })
+ )
+
+ const searchResult1: SearchResult = {
+ id: '1',
+ title: 'Old Title',
+ description: 'Search Desc',
+ tags: ['tag1'],
+ user_id: 'user-1',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ headline: 'Old Title',
+ rank: 0.8,
+ }
+
+ const resolved = result.current.resolveSearchResult(searchResult1)
+ expect(resolved.title).toBe('Updated Title')
+ expect(resolved.updated_at).toBe('2026-01-10T00:00:00Z')
+
+ // Searching for non-existent note returns searchResult unchanged
+ const unknownSearchResult: SearchResult = {
+ id: '99',
+ title: 'Unknown',
+ description: 'Unknown Desc',
+ tags: [],
+ user_id: 'user-1',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ rank: 0,
+ headline: null,
+ }
+
+ const resolvedUnknown = result.current.resolveSearchResult(unknownSearchResult)
+ expect(resolvedUnknown).toEqual(unknownSearchResult)
+ })
+
+ it('merges aggregated FTS search results correctly', () => {
+ const note1 = makeNote('1', { title: 'Fresh Title', updated_at: '2026-01-10T00:00:00Z' })
+ const notesQuery = createMockNotesQuery([note1])
+
+ const ftsData: AggregatedFtsData = {
+ results: [
+ {
+ id: '1',
+ title: 'Stale Title',
+ description: 'Desc',
+ tags: ['fts'],
+ user_id: 'user-1',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ rank: 0.95,
+ headline: null,
+ },
+ ],
+ total: 1,
+ method: 'fts',
+ executionTime: 12,
+ query: 'test',
+ }
+
+ const { result, rerender } = renderHook(
+ (props) => useNoteData(props),
+ {
+ initialProps: {
+ notesQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: ftsData,
+ selectedNoteIds: new Set(),
+ },
+ }
+ )
+
+ expect(result.current.mergedFtsData?.results[0].title).toBe('Fresh Title')
+
+ // Test with empty aggregated FTS results
+ const emptyFtsData: AggregatedFtsData = {
+ results: [],
+ total: 0,
+ method: 'fts',
+ executionTime: 0,
+ query: '',
+ }
+
+ rerender({
+ notesQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: emptyFtsData,
+ selectedNoteIds: new Set(),
+ })
+
+ expect(result.current.mergedFtsData).toBe(emptyFtsData)
+ })
+
+ it('maintains notesRef in sync with notes state across rerenders', () => {
+ const note1 = makeNote('1')
+ const initialQuery = createMockNotesQuery([note1])
+
+ const { result, rerender } = renderHook(
+ (props) => useNoteData(props),
+ {
+ initialProps: {
+ notesQuery: initialQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: undefined,
+ selectedNoteIds: new Set(),
+ },
+ }
+ )
+
+ expect(result.current.notesRef.current).toEqual([note1])
+
+ const note2 = makeNote('2')
+ const updatedQuery = createMockNotesQuery([note1, note2])
+
+ rerender({
+ notesQuery: updatedQuery,
+ offlineOverlay: [],
+ aggregatedFtsData: undefined,
+ selectedNoteIds: new Set(),
+ })
+
+ expect(result.current.notesRef.current).toEqual([note1, note2])
+ })
+})
diff --git a/ui/web/tests/unit/hooks/useNotesQuery.test.tsx b/ui/web/tests/unit/hooks/useNotesQuery.test.tsx
new file mode 100644
index 00000000000..7e5b3da3045
--- /dev/null
+++ b/ui/web/tests/unit/hooks/useNotesQuery.test.tsx
@@ -0,0 +1,424 @@
+import React from 'react'
+import { renderHook, waitFor, act } from '@testing-library/react'
+import { QueryClient, QueryClientProvider, InfiniteData } from '@tanstack/react-query'
+import type { Tables } from '@/supabase/types'
+import {
+ useNotesQuery,
+ useFlattenedNotes,
+ useSearchNotes,
+} from '@ui/web/hooks/useNotesQuery'
+import { useSupabase } from '@ui/web/providers/SupabaseProvider'
+
+type Note = Tables<'notes'>
+
+const mockNoteService = {
+ getNotes: jest.fn(),
+}
+
+const mockSearchService = {
+ searchNotes: jest.fn(),
+}
+
+const mockSupabase = {}
+
+jest.mock('@ui/web/providers/SupabaseProvider', () => ({
+ useSupabase: jest.fn(),
+}))
+
+jest.mock('@core/services/notes', () => ({
+ NoteService: jest.fn().mockImplementation(() => mockNoteService),
+}))
+
+jest.mock('@core/services/search', () => ({
+ SearchService: jest.fn().mockImplementation(() => mockSearchService),
+}))
+
+const makeNote = (overrides: Partial = {}): Note => ({
+ id: 'note-1',
+ user_id: 'user-1',
+ title: 'Test Note',
+ description: 'Test Description',
+ tags: ['work'],
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ ...overrides,
+})
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+ return function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children}
+ }
+}
+
+describe('useNotesQuery', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(useSupabase).mockReturnValue({
+ supabase: mockSupabase as never,
+ user: null,
+ loading: false,
+ })
+ })
+
+ it('fetches initial page of notes with default options', async () => {
+ const pageData = {
+ notes: [makeNote({ id: 'note-1' }), makeNote({ id: 'note-2' })],
+ nextCursor: 1,
+ totalCount: 2,
+ hasMore: true,
+ }
+ mockNoteService.getNotes.mockResolvedValue(pageData)
+
+ const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', {
+ page: 0,
+ pageSize: 50,
+ tag: null,
+ searchQuery: '',
+ })
+
+ expect(result.current.data?.pages[0]).toEqual(pageData)
+ })
+
+ it('passes search query and tag filter options to NoteService', async () => {
+ const pageData = {
+ notes: [makeNote({ id: 'note-filtered' })],
+ nextCursor: undefined,
+ totalCount: 1,
+ hasMore: false,
+ }
+ mockNoteService.getNotes.mockResolvedValue(pageData)
+
+ const { result } = renderHook(
+ () =>
+ useNotesQuery({
+ userId: 'user-1',
+ searchQuery: 'react',
+ selectedTag: 'frontend',
+ }),
+ { wrapper: createWrapper() }
+ )
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', {
+ page: 0,
+ pageSize: 50,
+ tag: 'frontend',
+ searchQuery: 'react',
+ })
+ })
+
+ it('does not execute query when enabled is false', () => {
+ const { result } = renderHook(
+ () => useNotesQuery({ userId: 'user-1', enabled: false }),
+ { wrapper: createWrapper() }
+ )
+
+ expect(mockNoteService.getNotes).not.toHaveBeenCalled()
+ expect(result.current.isFetching).toBe(false)
+ })
+
+ it('supports infinite pagination via fetchNextPage', async () => {
+ const page0 = {
+ notes: [makeNote({ id: 'page-0-note' })],
+ nextCursor: 1,
+ totalCount: 2,
+ hasMore: true,
+ }
+ const page1 = {
+ notes: [makeNote({ id: 'page-1-note' })],
+ nextCursor: undefined,
+ totalCount: 2,
+ hasMore: false,
+ }
+
+ mockNoteService.getNotes.mockImplementation((_userId, options) => {
+ if (options.page === 0) return Promise.resolve(page0)
+ if (options.page === 1) return Promise.resolve(page1)
+ return Promise.reject(new Error('Invalid page'))
+ })
+
+ const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+ expect(result.current.hasNextPage).toBe(true)
+
+ act(() => {
+ result.current.fetchNextPage()
+ })
+
+ await waitFor(() => expect(result.current.data?.pages).toHaveLength(2))
+
+ expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', expect.objectContaining({ page: 1 }))
+ expect(result.current.data?.pages[1]).toEqual(page1)
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it('handles error states when getNotes fails', async () => {
+ const error = new Error('Database connection failed')
+ mockNoteService.getNotes.mockRejectedValue(error)
+
+ const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isError).toBe(true))
+
+ expect(result.current.error).toEqual(error)
+ })
+
+ it('handles empty notes result correctly', async () => {
+ const emptyPage = {
+ notes: [],
+ nextCursor: undefined,
+ totalCount: 0,
+ hasMore: false,
+ }
+ mockNoteService.getNotes.mockResolvedValue(emptyPage)
+
+ const { result } = renderHook(() => useNotesQuery({ userId: 'user-1' }), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(result.current.data?.pages[0].notes).toEqual([])
+ expect(result.current.hasNextPage).toBe(false)
+ })
+
+ it('refetches when query parameters change', async () => {
+ mockNoteService.getNotes.mockResolvedValue({
+ notes: [],
+ nextCursor: undefined,
+ totalCount: 0,
+ hasMore: false,
+ })
+
+ const { rerender } = renderHook(
+ (props: Parameters[0]) => useNotesQuery(props),
+ {
+ initialProps: { userId: 'user-1', searchQuery: 'initial', selectedTag: null as string | null },
+ wrapper: createWrapper(),
+ }
+ )
+
+ await waitFor(() =>
+ expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', expect.objectContaining({ searchQuery: 'initial' }))
+ )
+
+ rerender({ userId: 'user-1', searchQuery: 'updated', selectedTag: 'newTag' })
+
+ await waitFor(() =>
+ expect(mockNoteService.getNotes).toHaveBeenCalledWith('user-1', expect.objectContaining({ searchQuery: 'updated', tag: 'newTag' }))
+ )
+ })
+})
+
+describe('useFlattenedNotes', () => {
+ it('returns empty array when queryResult has no data or empty pages', () => {
+ expect(useFlattenedNotes({})).toEqual([])
+ expect(useFlattenedNotes({ data: undefined })).toEqual([])
+ expect(useFlattenedNotes({ data: { pages: [], pageParams: [] } })).toEqual([])
+ })
+
+ it('flattens notes across multiple pages into a single array', () => {
+ const note1 = makeNote({ id: '1' })
+ const note2 = makeNote({ id: '2' })
+ const note3 = makeNote({ id: '3' })
+
+ const queryResult: { data: InfiniteData<{ notes: Note[]; totalCount: number; hasMore: boolean }> } = {
+ data: {
+ pages: [
+ { notes: [note1, note2], totalCount: 3, hasMore: true },
+ { notes: [note3], totalCount: 3, hasMore: false },
+ ],
+ pageParams: [0, 1],
+ },
+ }
+
+ expect(useFlattenedNotes(queryResult)).toEqual([note1, note2, note3])
+ })
+})
+
+describe('useSearchNotes', () => {
+ const originalLanguage = navigator.language
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(useSupabase).mockReturnValue({
+ supabase: mockSupabase as never,
+ user: null,
+ loading: false,
+ })
+ })
+
+ afterEach(() => {
+ Object.defineProperty(navigator, 'language', {
+ value: originalLanguage,
+ configurable: true,
+ })
+ })
+
+ it('does not execute search if query length is less than MIN_QUERY_LENGTH (3 characters)', () => {
+ const { result } = renderHook(() => useSearchNotes('ab', 'user-1'), {
+ wrapper: createWrapper(),
+ })
+
+ expect(mockSearchService.searchNotes).not.toHaveBeenCalled()
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+
+ it('does not execute search if query is whitespace-only', () => {
+ const { result } = renderHook(() => useSearchNotes(' ', 'user-1'), {
+ wrapper: createWrapper(),
+ })
+
+ expect(mockSearchService.searchNotes).not.toHaveBeenCalled()
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+
+ it('does not execute search if userId is missing', () => {
+ const { result } = renderHook(() => useSearchNotes('valid query', undefined), {
+ wrapper: createWrapper(),
+ })
+
+ expect(mockSearchService.searchNotes).not.toHaveBeenCalled()
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+
+ it('does not execute search when enabled is false', () => {
+ const { result } = renderHook(
+ () => useSearchNotes('valid query', 'user-1', { enabled: false }),
+ { wrapper: createWrapper() }
+ )
+
+ expect(mockSearchService.searchNotes).not.toHaveBeenCalled()
+ expect(result.current.fetchStatus).toBe('idle')
+ })
+
+ it('executes search with default options when query is valid and debounced', async () => {
+ const searchResult = {
+ notes: [makeNote({ id: 'search-1' })],
+ totalCount: 1,
+ hasMore: false,
+ }
+ mockSearchService.searchNotes.mockResolvedValue(searchResult)
+
+ const { result } = renderHook(() => useSearchNotes('testing', 'user-1'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockSearchService.searchNotes).toHaveBeenCalledWith('user-1', 'testing', {
+ language: expect.any(String),
+ minRank: 0.01,
+ limit: 50,
+ offset: 0,
+ tag: undefined,
+ })
+
+ expect(result.current.data).toEqual({
+ ...searchResult,
+ query: 'testing',
+ executionTime: expect.any(Number),
+ })
+ })
+
+ it('passes custom search options correctly', async () => {
+ const searchResult = { notes: [], totalCount: 0, hasMore: false }
+ mockSearchService.searchNotes.mockResolvedValue(searchResult)
+
+ const { result } = renderHook(
+ () =>
+ useSearchNotes('custom query', 'user-1', {
+ language: 'uk',
+ minRank: 0.1,
+ limit: 20,
+ offset: 10,
+ selectedTag: 'projects',
+ }),
+ { wrapper: createWrapper() }
+ )
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockSearchService.searchNotes).toHaveBeenCalledWith('user-1', 'custom query', {
+ language: 'uk',
+ minRank: 0.1,
+ limit: 20,
+ offset: 10,
+ tag: 'projects',
+ })
+ })
+
+ it('detects language from browser locale correctly', async () => {
+ Object.defineProperty(navigator, 'language', {
+ value: 'en-US',
+ configurable: true,
+ })
+
+ mockSearchService.searchNotes.mockResolvedValue({ notes: [], totalCount: 0, hasMore: false })
+
+ const { result } = renderHook(() => useSearchNotes('language test', 'user-1'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockSearchService.searchNotes).toHaveBeenCalledWith(
+ 'user-1',
+ 'language test',
+ expect.objectContaining({ language: 'en' })
+ )
+ })
+
+ it('defaults language to ru for unsupported browser locale', async () => {
+ Object.defineProperty(navigator, 'language', {
+ value: 'fr-FR',
+ configurable: true,
+ })
+
+ mockSearchService.searchNotes.mockResolvedValue({ notes: [], totalCount: 0, hasMore: false })
+
+ const { result } = renderHook(() => useSearchNotes('locale test', 'user-1'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(mockSearchService.searchNotes).toHaveBeenCalledWith(
+ 'user-1',
+ 'locale test',
+ expect.objectContaining({ language: 'ru' })
+ )
+ })
+
+ it('handles search errors correctly', async () => {
+ const searchError = new Error('Search index unavailable')
+ mockSearchService.searchNotes.mockRejectedValue(searchError)
+
+ const { result } = renderHook(() => useSearchNotes('error query', 'user-1'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => expect(result.current.isError).toBe(true))
+
+ expect(result.current.error).toEqual(searchError)
+ })
+})
diff --git a/ui/web/tests/unit/hooks/useTagSuggestions.test.ts b/ui/web/tests/unit/hooks/useTagSuggestions.test.ts
new file mode 100644
index 00000000000..8771afb707f
--- /dev/null
+++ b/ui/web/tests/unit/hooks/useTagSuggestions.test.ts
@@ -0,0 +1,167 @@
+import { renderHook } from '@testing-library/react'
+import { useTagSuggestions } from '@ui/web/hooks/useTagSuggestions'
+
+describe('useTagSuggestions', () => {
+ const defaultTags = ['react', 'react-dom', 'react-router', 'redux', 'refactor', 'rust']
+
+ it('returns empty array when query length is less than default minChars (3)', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: defaultTags,
+ selectedTags: [],
+ query: 're',
+ })
+ )
+
+ expect(result.current).toEqual([])
+ })
+
+ it('returns empty array for empty query string', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: defaultTags,
+ selectedTags: [],
+ query: '',
+ })
+ )
+
+ expect(result.current).toEqual([])
+ })
+
+ it('returns matching tag suggestions starting with the query when minChars condition is met', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: defaultTags,
+ selectedTags: [],
+ query: 'rea',
+ })
+ )
+
+ expect(result.current).toEqual(['react', 'react-dom', 'react-router'])
+ })
+
+ it('respects custom minChars option', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: defaultTags,
+ selectedTags: [],
+ query: 'ru',
+ minChars: 2,
+ })
+ )
+
+ expect(result.current).toEqual(['rust'])
+ })
+
+ it('filters out already selected tags from suggestions', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: defaultTags,
+ selectedTags: ['react'],
+ query: 'rea',
+ })
+ )
+
+ expect(result.current).toEqual(['react-dom', 'react-router'])
+ })
+
+ it('sorts suggestions alphabetically (localeCompare)', () => {
+ const unorderedTags = ['react-router', 'react', 'react-dom', 'react-native']
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: unorderedTags,
+ selectedTags: [],
+ query: 'rea',
+ limit: 10,
+ })
+ )
+
+ expect(result.current).toEqual(['react', 'react-dom', 'react-native', 'react-router'])
+ })
+
+ it('limits the number of returned suggestions to the specified limit', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: defaultTags,
+ selectedTags: [],
+ query: 'rea',
+ limit: 2,
+ })
+ )
+
+ expect(result.current).toHaveLength(2)
+ expect(result.current).toEqual(['react', 'react-dom'])
+ })
+
+ it('returns empty array when all matching tags are in selectedTags', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: ['react', 'react-dom'],
+ selectedTags: ['react', 'react-dom'],
+ query: 'rea',
+ })
+ )
+
+ expect(result.current).toEqual([])
+ })
+
+ it('returns empty array when allTags is empty', () => {
+ const { result } = renderHook(() =>
+ useTagSuggestions({
+ allTags: [],
+ selectedTags: [],
+ query: 'react',
+ })
+ )
+
+ expect(result.current).toEqual([])
+ })
+
+ it('memoizes the output when props do not change', () => {
+ const props = {
+ allTags: defaultTags,
+ selectedTags: [],
+ query: 'rea',
+ }
+ const { result, rerender } = renderHook((p) => useTagSuggestions(p), {
+ initialProps: props,
+ })
+
+ const firstResult = result.current
+ rerender(props)
+ const secondResult = result.current
+
+ expect(firstResult).toBe(secondResult)
+ })
+
+ it('updates suggestions when props (like query or selectedTags) change', () => {
+ const { result, rerender } = renderHook(
+ (props) => useTagSuggestions(props),
+ {
+ initialProps: {
+ allTags: defaultTags,
+ selectedTags: [] as string[],
+ query: 'rea',
+ },
+ }
+ )
+
+ expect(result.current).toEqual(['react', 'react-dom', 'react-router'])
+
+ rerender({
+ allTags: defaultTags,
+ selectedTags: ['react'],
+ query: 'rea',
+ })
+
+ expect(result.current).toEqual(['react-dom', 'react-router'])
+
+ rerender({
+ allTags: defaultTags,
+ selectedTags: [],
+ query: 'red',
+ })
+
+ expect(result.current).toEqual(['redux'])
+ })
+})
diff --git a/ui/web/tests/unit/lib/editor.test.ts b/ui/web/tests/unit/lib/editor.test.ts
new file mode 100644
index 00000000000..218ff691907
--- /dev/null
+++ b/ui/web/tests/unit/lib/editor.test.ts
@@ -0,0 +1,115 @@
+import type { Editor } from "@tiptap/react"
+import { SmartPasteService } from "@core/services/smartPaste"
+import { applySelectionAsMarkdown } from "@ui/web/lib/editor"
+
+function createMockEditor(from = 0, to = 10, selectedText = "# Heading 1") {
+ const runMock = jest.fn()
+ const insertContentMock = jest.fn().mockReturnValue({ run: runMock })
+ const deleteRangeMock = jest.fn().mockReturnValue({ insertContent: insertContentMock })
+ const focusMock = jest.fn().mockReturnValue({ deleteRange: deleteRangeMock })
+ const chainMock = jest.fn().mockReturnValue({ focus: focusMock })
+
+ const textBetweenMock = jest.fn().mockReturnValue(selectedText)
+
+ const editor = {
+ state: {
+ selection: { from, to },
+ doc: {
+ textBetween: textBetweenMock,
+ },
+ },
+ chain: chainMock,
+ } as unknown as Editor
+
+ return {
+ editor,
+ chainMock,
+ focusMock,
+ deleteRangeMock,
+ insertContentMock,
+ runMock,
+ textBetweenMock,
+ }
+}
+
+describe("applySelectionAsMarkdown", () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("does nothing when selection range is empty (from === to)", () => {
+ const { editor, chainMock, textBetweenMock } = createMockEditor(5, 5)
+ const onContentChange = jest.fn()
+ const resolvePasteSpy = jest.spyOn(SmartPasteService, "resolvePaste")
+
+ applySelectionAsMarkdown(editor, onContentChange)
+
+ expect(textBetweenMock).not.toHaveBeenCalled()
+ expect(resolvePasteSpy).not.toHaveBeenCalled()
+ expect(chainMock).not.toHaveBeenCalled()
+ expect(onContentChange).not.toHaveBeenCalled()
+ })
+
+ it("converts selected markdown text to HTML, replaces selection, and triggers onContentChange", () => {
+ const {
+ editor,
+ chainMock,
+ focusMock,
+ deleteRangeMock,
+ insertContentMock,
+ runMock,
+ textBetweenMock,
+ } = createMockEditor(0, 14, "**Bold Text**")
+ const onContentChange = jest.fn()
+ const resolvePasteSpy = jest.spyOn(SmartPasteService, "resolvePaste").mockReturnValueOnce({
+ html: "Bold Text",
+ type: "markdown",
+ } as ReturnType)
+
+ applySelectionAsMarkdown(editor, onContentChange)
+
+ expect(textBetweenMock).toHaveBeenCalledWith(0, 14, "\n\n")
+ expect(resolvePasteSpy).toHaveBeenCalledWith(
+ { html: null, text: "**Bold Text**", types: ["text/plain"] },
+ undefined,
+ "markdown"
+ )
+ expect(chainMock).toHaveBeenCalledTimes(1)
+ expect(focusMock).toHaveBeenCalledTimes(1)
+ expect(deleteRangeMock).toHaveBeenCalledWith({ from: 0, to: 14 })
+ expect(insertContentMock).toHaveBeenCalledWith("Bold Text")
+ expect(runMock).toHaveBeenCalledTimes(1)
+ expect(onContentChange).toHaveBeenCalledTimes(1)
+ resolvePasteSpy.mockRestore()
+ })
+
+ it("works safely when onContentChange callback is omitted", () => {
+ const { editor, runMock, insertContentMock } = createMockEditor(0, 10, "*Italic Text*")
+ const resolvePasteSpy = jest.spyOn(SmartPasteService, "resolvePaste").mockReturnValueOnce({
+ html: "Italic Text",
+ type: "markdown",
+ } as ReturnType)
+
+ expect(() => applySelectionAsMarkdown(editor)).not.toThrow()
+ expect(insertContentMock).toHaveBeenCalledWith("Italic Text")
+ expect(runMock).toHaveBeenCalledTimes(1)
+ resolvePasteSpy.mockRestore()
+ })
+
+ it("bails out when SmartPasteService returns empty HTML result", () => {
+ const { editor, chainMock } = createMockEditor(0, 5, "plain text")
+ const onContentChange = jest.fn()
+
+ jest.spyOn(SmartPasteService, "resolvePaste").mockReturnValueOnce({
+ html: "",
+ type: "markdown",
+ warnings: [],
+ detection: { type: "markdown", confidence: 1, reasons: [], warnings: [] },
+ })
+
+ applySelectionAsMarkdown(editor, onContentChange)
+
+ expect(chainMock).not.toHaveBeenCalled()
+ expect(onContentChange).not.toHaveBeenCalled()
+ })
+})