Skip to content

Commit fd21fcc

Browse files
committed
plugin: add tests for UI components
1 parent 6381c7f commit fd21fcc

File tree

16 files changed

+1249
-1
lines changed

16 files changed

+1249
-1
lines changed

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@
7373
"overrides": [
7474
{
7575
"includes": [
76-
"**/*.test.ts"
76+
"**/*.test.ts",
77+
"**/*.test.tsx"
7778
],
7879
"linter": {
7980
"rules": {

plugin/src/factories/data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function makeTask(id: string, opts?: Partial<Task>): Task {
2929
section: opts?.section,
3030

3131
due: opts?.due,
32+
duration: opts?.duration,
3233
deadline: opts?.deadline,
3334
};
3435
}

plugin/src/factories/settings.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Settings } from "@/settings";
2+
3+
export function makeSettings(overrides?: Partial<Settings>): Settings {
4+
return {
5+
apiTokenSecretId: "swt-todoist-api-token",
6+
tokenStorage: "secrets",
7+
fadeToggle: true,
8+
autoRefreshToggle: false,
9+
autoRefreshInterval: 60,
10+
renderDateIcon: true,
11+
renderProjectIcon: true,
12+
renderLabelsIcon: true,
13+
shouldWrapLinksInParens: false,
14+
addTaskButtonAddsPageLink: "content",
15+
taskCreationDefaultDueDate: "none",
16+
taskCreationDefaultProject: null,
17+
taskCreationDefaultLabels: [],
18+
defaultAddTaskAction: "add",
19+
debugLogging: false,
20+
version: 0,
21+
...overrides,
22+
};
23+
}

plugin/src/mocks/obsidian.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,57 @@
11
// biome-ignore lint/correctness/noUnusedFunctionParameters: mocks with empty impl
22
export function setIcon(parent: HTMLElement, iconId: string, size?: number): void {}
33

4+
// biome-ignore lint/correctness/noUnusedFunctionParameters: mocks with empty impl
5+
export function setTooltip(el: HTMLElement, text: string, options?: TooltipOptions): void {}
6+
7+
export type TooltipOptions = {
8+
placement?: string;
9+
};
10+
411
export class App {}
512
export class PluginSettingTab {}
613
export class Setting {}
14+
15+
export class MarkdownRenderChild {
16+
containerEl: HTMLElement;
17+
constructor(containerEl: HTMLElement) {
18+
this.containerEl = containerEl;
19+
}
20+
}
21+
22+
export class Notice {}
23+
24+
export class Menu {
25+
addItem(cb: (item: MenuItem) => void): this {
26+
cb(new MenuItem());
27+
return this;
28+
}
29+
// biome-ignore lint/correctness/noUnusedFunctionParameters: mocks with empty impl
30+
showAtPosition(position: { x: number; y: number }): this {
31+
return this;
32+
}
33+
}
34+
35+
export class MenuItem {
36+
// biome-ignore lint/correctness/noUnusedFunctionParameters: mocks with empty impl
37+
setTitle(title: string): this {
38+
return this;
39+
}
40+
// biome-ignore lint/correctness/noUnusedFunctionParameters: mocks with empty impl
41+
onClick(cb: (evt: MouseEvent | KeyboardEvent) => void): this {
42+
return this;
43+
}
44+
}
45+
46+
// biome-ignore lint/complexity/noStaticOnlyClass: mock must match Obsidian's class-based API
47+
export class MarkdownRenderer {
48+
static renderMarkdown(
49+
markdown: string,
50+
el: HTMLElement,
51+
_sourcePath: string,
52+
_component: unknown,
53+
): Promise<void> {
54+
el.textContent = markdown;
55+
return Promise.resolve();
56+
}
57+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { Callout, type Contents } from "./index";
5+
6+
describe("Callout", () => {
7+
it("should render title and icon", () => {
8+
render(<Callout title="Test Title" iconId="info" className="test-class" />);
9+
10+
expect(screen.getByText("Test Title")).toBeInTheDocument();
11+
});
12+
13+
it("should render flat string contents as list items", () => {
14+
const contents: Contents[] = ["Item 1", "Item 2", "Item 3"];
15+
16+
render(<Callout title="Title" iconId="info" className="test" contents={contents} />);
17+
18+
expect(screen.getByText("Item 1")).toBeInTheDocument();
19+
expect(screen.getByText("Item 2")).toBeInTheDocument();
20+
expect(screen.getByText("Item 3")).toBeInTheDocument();
21+
22+
const listItems = document.querySelectorAll("li");
23+
expect(listItems).toHaveLength(3);
24+
});
25+
26+
it("should render nested Contents objects with recursive lists", () => {
27+
const contents: Contents[] = [
28+
{
29+
msg: "Parent",
30+
children: ["Child 1", "Child 2"],
31+
},
32+
];
33+
34+
render(<Callout title="Title" iconId="info" className="test" contents={contents} />);
35+
36+
expect(screen.getByText("Parent")).toBeInTheDocument();
37+
expect(screen.getByText("Child 1")).toBeInTheDocument();
38+
expect(screen.getByText("Child 2")).toBeInTheDocument();
39+
40+
const nestedList = document.querySelectorAll("ul ul");
41+
expect(nestedList).toHaveLength(1);
42+
});
43+
44+
it("should render with no contents and no list", () => {
45+
const { container } = render(<Callout title="Title" iconId="info" className="test" />);
46+
47+
expect(container.querySelector(".callout-contents")).not.toBeInTheDocument();
48+
});
49+
50+
it("should apply className to root element", () => {
51+
const { container } = render(<Callout title="Title" iconId="info" className="my-class" />);
52+
53+
expect(container.querySelector(".todoist-callout.my-class")).toBeInTheDocument();
54+
});
55+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { render } from "@testing-library/react";
2+
import * as obsidian from "obsidian";
3+
import { describe, expect, it, type MockInstance, vi } from "vitest";
4+
5+
import { ObsidianIcon } from "./index";
6+
7+
describe("ObsidianIcon", () => {
8+
it("should render div with obsidian-icon class and correct data-icon-size", () => {
9+
const { container } = render(<ObsidianIcon id="check" size="m" />);
10+
11+
const div = container.querySelector(".obsidian-icon");
12+
expect(div).toBeInTheDocument();
13+
expect(div).toHaveAttribute("data-icon-size", "m");
14+
});
15+
16+
it("should call setIcon with correct element and icon ID", () => {
17+
const spy: MockInstance = vi.spyOn(obsidian, "setIcon");
18+
19+
render(<ObsidianIcon id="my-icon" size="s" />);
20+
21+
expect(spy).toHaveBeenCalledWith(expect.any(HTMLElement), "my-icon");
22+
spy.mockRestore();
23+
});
24+
25+
it("should pass through additional HTML attributes", () => {
26+
const { container } = render(
27+
<ObsidianIcon id="check" size="l" data-testid="custom" aria-label="icon" />,
28+
);
29+
30+
const div = container.querySelector(".obsidian-icon");
31+
expect(div).toHaveAttribute("data-testid", "custom");
32+
expect(div).toHaveAttribute("aria-label", "icon");
33+
});
34+
35+
it("should merge additional className", () => {
36+
const { container } = render(<ObsidianIcon id="check" size="s" className="extra-class" />);
37+
38+
const div = container.querySelector(".obsidian-icon.extra-class");
39+
expect(div).toBeInTheDocument();
40+
});
41+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import type { TokenValidation } from "@/token";
5+
6+
import { TokenValidationIcon } from "./index";
7+
8+
describe("TokenValidationIcon", () => {
9+
it("should return null for kind 'none'", () => {
10+
const status: TokenValidation.Result = { kind: "none" };
11+
const { container } = render(<TokenValidationIcon status={status} />);
12+
13+
expect(container).toBeEmptyDOMElement();
14+
});
15+
16+
it("should render loader icon for 'in-progress' status", () => {
17+
const status: TokenValidation.Result = { kind: "in-progress" };
18+
const { container } = render(<TokenValidationIcon status={status} />);
19+
20+
const icon = container.querySelector(".token-validation-in-progress");
21+
expect(icon).toBeInTheDocument();
22+
});
23+
24+
it("should render error icon for 'error' status", () => {
25+
const status: TokenValidation.Result = { kind: "error", message: "Bad token" };
26+
const { container } = render(<TokenValidationIcon status={status} />);
27+
28+
const icon = container.querySelector(".token-validation-error");
29+
expect(icon).toBeInTheDocument();
30+
});
31+
32+
it("should render success icon for 'success' status", () => {
33+
const status: TokenValidation.Result = { kind: "success" };
34+
const { container } = render(<TokenValidationIcon status={status} />);
35+
36+
const icon = container.querySelector(".token-validation-success");
37+
expect(icon).toBeInTheDocument();
38+
});
39+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { ParsingError } from "@/query/parser";
5+
6+
import { QueryError } from "./QueryError";
7+
8+
describe("QueryError", () => {
9+
it("should render ParsingError messages", () => {
10+
const error = new ParsingError(["Invalid filter", "Missing field"]);
11+
12+
render(<QueryError error={error} />);
13+
14+
expect(screen.getByText("Error: Query parsing failed")).toBeInTheDocument();
15+
expect(screen.getByText("Invalid filter")).toBeInTheDocument();
16+
expect(screen.getByText("Missing field")).toBeInTheDocument();
17+
});
18+
19+
it("should render generic Error message", () => {
20+
const error = new Error("Something went wrong");
21+
22+
render(<QueryError error={error} />);
23+
24+
expect(screen.getByText("Error: Query parsing failed")).toBeInTheDocument();
25+
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
26+
});
27+
28+
it("should render unknown error fallback for non-Error objects", () => {
29+
render(<QueryError error="not an error" />);
30+
31+
expect(screen.getByText("Error: Query parsing failed")).toBeInTheDocument();
32+
expect(screen.getByText(/Unknown error occurred/)).toBeInTheDocument();
33+
});
34+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { render, screen } from "@testing-library/react";
2+
import type React from "react";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { create } from "zustand";
5+
6+
import { type MarkdownEditButton, MarkdownEditButtonContext, PluginContext } from "@/ui/context";
7+
8+
import { QueryHeader } from "./QueryHeader";
9+
10+
const mockPlugin = {
11+
services: {
12+
todoist: {
13+
actions: {
14+
closeTask: vi.fn(),
15+
},
16+
},
17+
},
18+
} as unknown as ReturnType<typeof PluginContext.use>;
19+
20+
const mockEditClick = vi.fn();
21+
const mockEditButtonStore = create<MarkdownEditButton>(() => ({
22+
click: mockEditClick,
23+
}));
24+
25+
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
26+
return (
27+
<PluginContext.Provider value={mockPlugin}>
28+
<MarkdownEditButtonContext.Provider value={mockEditButtonStore}>
29+
{children}
30+
</MarkdownEditButtonContext.Provider>
31+
</PluginContext.Provider>
32+
);
33+
};
34+
35+
describe("QueryHeader", () => {
36+
it("should render query title", () => {
37+
render(
38+
<QueryHeader
39+
title="My Tasks"
40+
isFetching={false}
41+
refresh={vi.fn()}
42+
refreshedTimestamp={undefined}
43+
/>,
44+
{ wrapper: Wrapper },
45+
);
46+
47+
expect(screen.getByText("My Tasks")).toBeInTheDocument();
48+
});
49+
50+
it("should render three control buttons", () => {
51+
const { container } = render(
52+
<QueryHeader
53+
title="Tasks"
54+
isFetching={false}
55+
refresh={vi.fn()}
56+
refreshedTimestamp={undefined}
57+
/>,
58+
{ wrapper: Wrapper },
59+
);
60+
61+
expect(container.querySelector(".add-task")).toBeInTheDocument();
62+
expect(container.querySelector(".refresh-query")).toBeInTheDocument();
63+
expect(container.querySelector(".edit-query")).toBeInTheDocument();
64+
});
65+
66+
it("should show 'is-refreshing' class during fetch", () => {
67+
const { container } = render(
68+
<QueryHeader
69+
title="Tasks"
70+
isFetching={true}
71+
refresh={vi.fn()}
72+
refreshedTimestamp={undefined}
73+
/>,
74+
{ wrapper: Wrapper },
75+
);
76+
77+
expect(container.querySelector(".refresh-query.is-refreshing")).toBeInTheDocument();
78+
});
79+
80+
it("should not show 'is-refreshing' class when not fetching", () => {
81+
const { container } = render(
82+
<QueryHeader
83+
title="Tasks"
84+
isFetching={false}
85+
refresh={vi.fn()}
86+
refreshedTimestamp={undefined}
87+
/>,
88+
{ wrapper: Wrapper },
89+
);
90+
91+
expect(container.querySelector(".refresh-query.is-refreshing")).not.toBeInTheDocument();
92+
});
93+
});

0 commit comments

Comments
 (0)