Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions webview/__tests__/components/atoms/ListItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,22 @@ describe("ListItem", () => {
expect(container.querySelector(".root")?.classList.contains("custom")).toBe(true);
});
});

// when focused is true
context("focused が true の場合", () => {
// has data-focused attribute
it("data-focused 属性を持つこと", () => {
const { container } = render(<ListItem title="index.ts" focused={true} />);
expect(container.querySelector(".root")?.getAttribute("data-focused")).toBe("true");
});
});

// when focused is false or undefined
context("focused が false または未指定の場合", () => {
// does not have data-focused attribute
it("data-focused 属性を持たないこと", () => {
const { container } = render(<ListItem title="index.ts" focused={false} />);
expect(container.querySelector(".root")?.hasAttribute("data-focused")).toBe(false);
});
});
});
22 changes: 13 additions & 9 deletions webview/__tests__/components/molecules/AgentPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe("AgentPopup", () => {
// renders agent names
it("エージェント名を表示すること", () => {
const { container } = render(
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} />,
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} focusedIndex={-1} />,
);
const titles = container.querySelectorAll(".title");
expect(titles[0]?.textContent).toBe("coder");
Expand All @@ -33,7 +33,7 @@ describe("AgentPopup", () => {
// renders agent descriptions
it("エージェントの説明を表示すること", () => {
const { container } = render(
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} />,
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} focusedIndex={-1} />,
);
const descriptions = container.querySelectorAll(".description");
expect(descriptions[0]?.textContent).toBe("Coding agent");
Expand All @@ -44,22 +44,26 @@ describe("AgentPopup", () => {
const onSelect = vi.fn();
const user = userEvent.setup();
const { container } = render(
<AgentPopup agents={agents} onSelectAgent={onSelect} agentPopupRef={{ current: null }} />,
<AgentPopup agents={agents} onSelectAgent={onSelect} agentPopupRef={{ current: null }} focusedIndex={-1} />,
);
const items = container.querySelectorAll(".root > div");
await user.click(items[0]!);
expect(onSelect).toHaveBeenCalledWith(agents[0]);
});
});

// when no agents available
context("エージェントがない場合", () => {
// shows empty message
it("空メッセージを表示すること", () => {
// when focusedIndex highlights a specific agent
context("focusedIndex が指定された場合", () => {
const agents = [createAgent("coder", "Coding agent"), createAgent("researcher", "Research agent")];

// applies data-focused to the correct item
it("対応するアイテムに data-focused 属性が付与されること", () => {
const { container } = render(
<AgentPopup agents={[]} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} />,
<AgentPopup agents={agents} onSelectAgent={vi.fn()} agentPopupRef={{ current: null }} focusedIndex={0} />,
);
expect(container.querySelector(".empty")?.textContent).toBe("No agents available");
const items = container.querySelectorAll(".root > div");
expect(items[0]?.getAttribute("data-focused")).toBe("true");
expect(items[1]?.hasAttribute("data-focused")).toBe(false);
});
});
});
21 changes: 18 additions & 3 deletions webview/__tests__/components/molecules/HashFilePopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("HashFilePopup", () => {
// renders file items
it("ファイルアイテムをレンダリングすること", () => {
const { container } = render(
<HashFilePopup hashFiles={[file1, file2]} onAddFile={vi.fn()} hashPopupRef={createRef()} />,
<HashFilePopup hashFiles={[file1, file2]} onAddFile={vi.fn()} hashPopupRef={createRef()} focusedIndex={-1} />,
);
expect(container.querySelectorAll("[data-testid='hash-popup'] > div")).toHaveLength(2);
});
Expand All @@ -25,7 +25,7 @@ describe("HashFilePopup", () => {
it("アイテムクリックで onAddFile が呼ばれること", () => {
const onAddFile = vi.fn();
const { container } = render(
<HashFilePopup hashFiles={[file1]} onAddFile={onAddFile} hashPopupRef={createRef()} />,
<HashFilePopup hashFiles={[file1]} onAddFile={onAddFile} hashPopupRef={createRef()} focusedIndex={-1} />,
);
fireEvent.click(container.querySelector("[data-testid='hash-popup'] > div")!);
expect(onAddFile).toHaveBeenCalledWith(file1);
Expand All @@ -36,8 +36,23 @@ describe("HashFilePopup", () => {
context("ファイル候補がない場合", () => {
// renders empty message
it("空メッセージを表示すること", () => {
const { container } = render(<HashFilePopup hashFiles={[]} onAddFile={vi.fn()} hashPopupRef={createRef()} />);
const { container } = render(
<HashFilePopup hashFiles={[]} onAddFile={vi.fn()} hashPopupRef={createRef()} focusedIndex={-1} />,
);
expect(container.querySelector(".empty")).toBeInTheDocument();
});
});

// when focusedIndex highlights a specific item
context("focusedIndex が指定された場合", () => {
// applies data-focused to the correct item
it("対応するアイテムに data-focused 属性が付与されること", () => {
const { container } = render(
<HashFilePopup hashFiles={[file1, file2]} onAddFile={vi.fn()} hashPopupRef={createRef()} focusedIndex={1} />,
);
const items = container.querySelectorAll("[data-testid='hash-popup'] > div");
expect(items[0]?.hasAttribute("data-focused")).toBe(false);
expect(items[1]?.getAttribute("data-focused")).toBe("true");
});
});
});
262 changes: 262 additions & 0 deletions webview/__tests__/scenarios/21-popup-tab-select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { postMessage } from "../../vscode-api";
import { createSession } from "../factories";
import { renderApp, sendExtMessage } from "../helpers";

/** テスト用エージェントデータ */
const testAgents = [
{
name: "coder",
description: "Coding subagent",
mode: "subagent",
builtIn: true,
permission: { edit: "ask", bash: {} },
tools: {},
options: {},
},
{
name: "explorer",
description: "Read-only subagent",
mode: "subagent",
builtIn: true,
permission: { edit: "deny", bash: {} },
tools: {},
options: {},
},
] as any;

/** ファイル候補付きセットアップ */
async function setupWithFiles() {
renderApp();
await sendExtMessage({ type: "activeSession", session: createSession({ id: "s1" }) });
await sendExtMessage({
type: "openEditors",
files: [
{ filePath: "src/main.ts", fileName: "main.ts" },
{ filePath: "src/utils.ts", fileName: "utils.ts" },
],
});
await sendExtMessage({
type: "workspaceFiles",
files: [
{ filePath: "src/main.ts", fileName: "main.ts" },
{ filePath: "src/utils.ts", fileName: "utils.ts" },
{ filePath: "src/config.ts", fileName: "config.ts" },
],
});
await sendExtMessage({ type: "agents", agents: testAgents });
vi.mocked(postMessage).mockClear();
}

// Tab selection in inline popups
describe("ポップアップの Tab 選択", () => {
beforeEach(async () => {
await setupWithFiles();
});

// # popup: Tab moves focus through items
context("# ポップアップで Tab を押した場合", () => {
// first item gets focused
it("先頭のアイテムにフォーカスが当たること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{Tab}");
const popup = document.querySelector("[data-testid='hash-popup']");
const items = popup?.querySelectorAll(":scope > div");
expect(items?.[0]?.getAttribute("data-focused")).toBe("true");
});

// Tab again moves to second item
it("もう一度 Tab を押すと 2 番目のアイテムにフォーカスが移ること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{Tab}");
await user.keyboard("{Tab}");
const popup = document.querySelector("[data-testid='hash-popup']");
const items = popup?.querySelectorAll(":scope > div");
expect(items?.[1]?.getAttribute("data-focused")).toBe("true");
});
});

// # popup: Tab + Enter selects the focused item
context("# ポップアップで Tab でフォーカス後 Enter で確定した場合", () => {
// file is attached and popup closes
it("ファイルが添付されポップアップが閉じること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{Tab}");
await user.keyboard("{Enter}");
// ポップアップが閉じる
expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy();
// ファイルチップが表示される
expect(document.querySelectorAll(".chip").length).toBe(1);
});
});

// # popup: ArrowDown moves focus
context("# ポップアップで ↓ キーを押した場合", () => {
// second item gets focused
it("次のアイテムにフォーカスが移ること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{ArrowDown}");
const popup = document.querySelector("[data-testid='hash-popup']");
const items = popup?.querySelectorAll(":scope > div");
expect(items?.[0]?.getAttribute("data-focused")).toBe("true");
});

// pressing ArrowDown again moves to second item
it("もう一度 ↓ を押すと2番目のアイテムにフォーカスが移ること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{ArrowDown}");
await user.keyboard("{ArrowDown}");
const popup = document.querySelector("[data-testid='hash-popup']");
const items = popup?.querySelectorAll(":scope > div");
expect(items?.[1]?.getAttribute("data-focused")).toBe("true");
});
});

// # popup: ArrowUp wraps to last item
context("# ポップアップで ↑ キーを押した場合", () => {
// wraps to last item
it("末尾のアイテムにフォーカスが移ること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{ArrowUp}");
const popup = document.querySelector("[data-testid='hash-popup']");
const items = popup?.querySelectorAll(":scope > div");
const lastItem = items?.[items.length - 1];
expect(lastItem?.getAttribute("data-focused")).toBe("true");
});
});

// # popup: Enter on focused item selects it
context("# ポップアップでフォーカス済みアイテムで Enter を押した場合", () => {
// file is attached and popup closes
it("ファイルが添付されポップアップが閉じること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
// ポップアップが閉じる
expect(document.querySelector("[data-testid='hash-popup']")).toBeFalsy();
// ファイルチップが表示される
expect(document.querySelectorAll(".chip").length).toBe(1);
});
});

// # popup: Enter without focus sends message
context("# ポップアップ表示中にフォーカスなしで Enter を押した場合", () => {
// sends the message (existing behavior)
it("メッセージが送信されること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "hello #");
await user.keyboard("{Enter}");
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "sendMessage",
text: "hello #",
}),
);
});
});

// # popup: query change resets focus
context("# ポップアップでクエリを変更した場合", () => {
// focus resets
it("フォーカスがリセットされること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "#");
await user.keyboard("{ArrowDown}");
// フォーカスが当たっている
let popup = document.querySelector("[data-testid='hash-popup']");
expect(popup?.querySelector("[data-focused]")).toBeTruthy();
// クエリを入力するとフォーカスリセット
await user.type(textarea, "m");
popup = document.querySelector("[data-testid='hash-popup']");
expect(popup?.querySelector("[data-focused]")).toBeFalsy();
});
});

// @ popup: Tab moves focus through agents
context("@ ポップアップで Tab を押した場合", () => {
// first agent gets focused
it("先頭のエージェントにフォーカスが当たること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "@");
await user.keyboard("{Tab}");
const popup = document.querySelector("[data-testid='agent-popup']");
const items = popup?.querySelectorAll(":scope > div");
expect(items?.[0]?.getAttribute("data-focused")).toBe("true");
});

// Tab again moves to second agent
it("もう一度 Tab を押すと 2 番目のエージェントにフォーカスが移ること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "@");
await user.keyboard("{Tab}");
await user.keyboard("{Tab}");
const popup = document.querySelector("[data-testid='agent-popup']");
const items = popup?.querySelectorAll(":scope > div");
expect(items?.[1]?.getAttribute("data-focused")).toBe("true");
});
});

// @ popup: Tab + Enter selects the focused agent
context("@ ポップアップで Tab でフォーカス後 Enter で確定した場合", () => {
// agent is selected and popup closes
it("エージェントが選択されポップアップが閉じること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "@");
await user.keyboard("{Tab}");
await user.keyboard("{Enter}");
expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument();
expect(screen.getByText("@coder")).toBeInTheDocument();
});
});

// @ popup: Enter on focused agent selects it
context("@ ポップアップでフォーカス済みエージェントで Enter を押した場合", () => {
// agent is selected
it("エージェントが選択されること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "@");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
expect(screen.queryByTestId("agent-popup")).not.toBeInTheDocument();
expect(screen.getByText("@coder")).toBeInTheDocument();
});
});

// @ popup: ArrowDown/ArrowUp navigation
context("@ ポップアップで ↓↑ キーで移動した場合", () => {
// ArrowDown then ArrowUp returns to first
it("↓ で 2 番目、↑ で 1 番目に戻ること", async () => {
const user = userEvent.setup();
const textarea = screen.getByPlaceholderText("Ask OpenCode... (type # to attach files)");
await user.type(textarea, "@");
await user.keyboard("{ArrowDown}");
await user.keyboard("{ArrowDown}");
const popup = document.querySelector("[data-testid='agent-popup']");
expect(popup?.querySelectorAll(":scope > div")[1]?.getAttribute("data-focused")).toBe("true");
await user.keyboard("{ArrowUp}");
expect(popup?.querySelectorAll(":scope > div")[0]?.getAttribute("data-focused")).toBe("true");
});
});
});
3 changes: 2 additions & 1 deletion webview/components/atoms/ListItem/ListItem.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
font-size: 12px;
}

.root:hover {
.root:hover,
.root[data-focused] {
background-color: var(--vscode-list-hoverBackground);
}

Expand Down
Loading