feat(FN-1731): merge fusion/fn-1731
This commit is contained in:
@@ -318,7 +318,7 @@ function AppInner() {
|
||||
if (taskView === "skills") {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<SkillsView addToast={addToast} projectId={currentProject?.id} />
|
||||
<SkillsView addToast={addToast} projectId={currentProject?.id} onClose={() => handleChangeTaskView("board")} />
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3884,3 +3884,311 @@ describe("Roadmap API wrappers", () => {
|
||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/roadmaps/milestones/RMS-001/features");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Settings API wrapper tests for FN-1712 (scope-split settings UX).
|
||||
* These tests verify the API contract for:
|
||||
* - fetchSettingsByScope: Returns { global, project } scoped settings
|
||||
* - updateGlobalSettings: PUT /api/settings/global
|
||||
* - updateSettings: PUT /api/settings (project scope)
|
||||
* - fetchGlobalSettings: GET /api/settings/global
|
||||
*/
|
||||
describe("Settings API wrappers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchSettingsByScope", () => {
|
||||
it("calls /api/settings/scopes with no query string when projectId is omitted", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ global: { themeMode: "dark" }, project: {} }),
|
||||
text: () => Promise.resolve(JSON.stringify({ global: { themeMode: "dark" }, project: {} })),
|
||||
} as unknown as Response);
|
||||
|
||||
await fetchSettingsByScope();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/settings/scopes", expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
// GET is the default method, so method should not be specified
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[1].method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls /api/settings/scopes?projectId=proj_123 when projectId is provided", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ global: { themeMode: "dark" }, project: {} }),
|
||||
text: () => Promise.resolve(JSON.stringify({ global: { themeMode: "dark" }, project: {} })),
|
||||
} as unknown as Response);
|
||||
|
||||
await fetchSettingsByScope("proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/settings/scopes?projectId=proj_123", expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
// GET is the default method
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[1].method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the { global, project } shape", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
const mockResponse = {
|
||||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
text: () => Promise.resolve(JSON.stringify(mockResponse)),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await fetchSettingsByScope();
|
||||
|
||||
expect(result).toHaveProperty("global");
|
||||
expect(result).toHaveProperty("project");
|
||||
expect(result.global.themeMode).toBe("dark");
|
||||
expect(result.global.defaultProvider).toBe("anthropic");
|
||||
expect(result.project.planningProvider).toBe("openai");
|
||||
});
|
||||
|
||||
it("throws with server error message on failure", async () => {
|
||||
const { fetchSettingsByScope } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ error: "Database connection failed" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: "Database connection failed" })),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(fetchSettingsByScope()).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateGlobalSettings", () => {
|
||||
it("sends PUT to /api/settings/global with the provided payload", async () => {
|
||||
const { updateGlobalSettings } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ themeMode: "light" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ themeMode: "light" })),
|
||||
} as unknown as Response);
|
||||
|
||||
await updateGlobalSettings({ themeMode: "light" });
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("/api/settings/global");
|
||||
expect(options.method).toBe("PUT");
|
||||
expect(JSON.parse(options.body as string)).toEqual({ themeMode: "light" });
|
||||
});
|
||||
|
||||
it("returns the settings object on success", async () => {
|
||||
const { updateGlobalSettings } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ themeMode: "light", defaultProvider: "anthropic" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ themeMode: "light", defaultProvider: "anthropic" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await updateGlobalSettings({ defaultProvider: "anthropic" });
|
||||
|
||||
expect(result.themeMode).toBe("light");
|
||||
expect(result.defaultProvider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("throws with server error message on failure", async () => {
|
||||
const { updateGlobalSettings } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ error: "Invalid settings format" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: "Invalid settings format" })),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(updateGlobalSettings({})).rejects.toThrow("Invalid settings format");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSettings scope rejection", () => {
|
||||
it("forwards payload to PUT /api/settings and surfaces resulting 400 error", async () => {
|
||||
const { updateSettings } = await import("./api");
|
||||
|
||||
// The backend rejects global keys on PUT /api/settings
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ error: "Global settings keys not allowed in project scope" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: "Global settings keys not allowed in project scope" })),
|
||||
} as unknown as Response);
|
||||
|
||||
// This documents the client/server contract: sending global keys to project endpoint fails
|
||||
await expect(updateSettings({ themeMode: "light" })).rejects.toThrow(
|
||||
"Global settings keys not allowed in project scope"
|
||||
);
|
||||
|
||||
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("/api/settings");
|
||||
expect(options.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("sends PUT to /api/settings with project-scoped payload on success", async () => {
|
||||
const { updateSettings } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ planningProvider: "openai" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ planningProvider: "openai" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await updateSettings({ planningProvider: "openai", planningModelId: "gpt-4o" });
|
||||
|
||||
expect(result.planningProvider).toBe("openai");
|
||||
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("/api/settings");
|
||||
expect(options.method).toBe("PUT");
|
||||
expect(JSON.parse(options.body as string)).toEqual({
|
||||
planningProvider: "openai",
|
||||
planningModelId: "gpt-4o",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGlobalSettings", () => {
|
||||
it("calls GET /api/settings/global with no query string", async () => {
|
||||
const { fetchGlobalSettings } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ themeMode: "dark" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ themeMode: "dark" })),
|
||||
} as unknown as Response);
|
||||
|
||||
await fetchGlobalSettings();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/settings/global", expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
// GET is the default method
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[1].method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns GlobalSettings with known keys like themeMode", async () => {
|
||||
const { fetchGlobalSettings } = await import("./api");
|
||||
const mockSettings = {
|
||||
themeMode: "light",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve(mockSettings),
|
||||
text: () => Promise.resolve(JSON.stringify(mockSettings)),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await fetchGlobalSettings();
|
||||
|
||||
expect(result.themeMode).toBe("light");
|
||||
expect(result.defaultProvider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("throws with server error message on failure", async () => {
|
||||
const { fetchGlobalSettings } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ error: "Settings file corrupted" }),
|
||||
text: () => Promise.resolve(JSON.stringify({ error: "Settings file corrupted" })),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3303,4 +3303,346 @@ describe("Prompts section", () => {
|
||||
expect(onReopenOnboarding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression tests for FN-1712: Settings UI model-lane scope UX.
|
||||
*
|
||||
* These tests guard the scope-split behavior introduced in FN-1712:
|
||||
* - Each model setting is rendered as a lane with dual controls (global baseline + project override)
|
||||
* - Inheritance indicators show whether a lane is overridden or inherited
|
||||
* - Save payloads are split by scope: global keys go to updateGlobalSettings, project keys to updateSettings
|
||||
* - Reset/clear interactions use null-as-delete semantics
|
||||
*
|
||||
* DOM selectors used:
|
||||
* - Project model lanes: id="${laneId}Model" (e.g., "planningModel", "validatorModel")
|
||||
* - Lane badges: .settings-lane-badge--override or .settings-lane-badge--inherited
|
||||
* - Badge text: "Override (Project)" or "Inherited (Global)"
|
||||
* - Reset button: text "Reset" in lane
|
||||
* - Fallback models: id="planningFallbackModel", id="validatorFallbackModel"
|
||||
* - Global models: id="defaultModel", id="fallbackModel", id="defaultThinkingLevel"
|
||||
*/
|
||||
|
||||
describe("Model lane rendering (FN-1712 scope UX)", () => {
|
||||
it("renders Project Models section with all three model lanes", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Project Models section
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify all three model lanes are rendered with labels
|
||||
expect(screen.getByLabelText("Planning Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Validator Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Title Summarization Model")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Default Model and Fallback Model in global Models section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Navigate to Models section (global)
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify default and fallback model dropdowns
|
||||
expect(screen.getByLabelText("Default Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Fallback Model")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Thinking Effort select in global Models section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Thinking Effort is a native select
|
||||
const select = screen.getByLabelText("Thinking Effort");
|
||||
expect(select.tagName).toBe("SELECT");
|
||||
|
||||
// Verify expected options
|
||||
const options = Array.from(select.querySelectorAll("option")).map((o) => o.textContent);
|
||||
expect(options).toContain("Default");
|
||||
expect(options).toContain("Off");
|
||||
expect(options).toContain("Minimal");
|
||||
expect(options).toContain("Low");
|
||||
expect(options).toContain("Medium");
|
||||
expect(options).toContain("High");
|
||||
});
|
||||
|
||||
it("renders Planning Fallback and Validator Fallback in project Models section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Verify fallback model dropdowns
|
||||
expect(screen.getByLabelText("Planning Fallback Model")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Validator Fallback Model")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Inherited badge when project override is not set", async () => {
|
||||
// Mock fetchSettingsByScope to return project with no overrides
|
||||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||||
project: {},
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Planning lane should show Inherited badge
|
||||
const planningLabel = screen.getByLabelText("Planning Model").closest(".form-group");
|
||||
expect(planningLabel?.querySelector(".settings-lane-badge--inherited")).toBeTruthy();
|
||||
expect(planningLabel?.textContent).toContain("Inherited (Global)");
|
||||
});
|
||||
|
||||
it("shows Override badge when project override is set", async () => {
|
||||
// Mock fetchSettingsByScope to return project with override
|
||||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
global: { themeMode: "dark", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" },
|
||||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Planning lane should show Override badge
|
||||
const planningLabel = screen.getByLabelText("Planning Model").closest(".form-group");
|
||||
expect(planningLabel?.querySelector(".settings-lane-badge--override")).toBeTruthy();
|
||||
expect(planningLabel?.textContent).toContain("Override (Project)");
|
||||
|
||||
// Should show Reset button when overridden
|
||||
expect(planningLabel?.textContent).toContain("Reset");
|
||||
});
|
||||
|
||||
it("shows fallback text when both global and project are unset (automatic selection)", async () => {
|
||||
// Mock fetchSettingsByScope to return empty values
|
||||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
global: { themeMode: "dark" }, // No defaultProvider/defaultModelId set
|
||||
project: {}, // No override set
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Default Model dropdown should show placeholder
|
||||
const dropdown = screen.getByLabelText("Default Model");
|
||||
expect(dropdown.textContent || dropdown.getAttribute("value")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Save payload scope-split (FN-1712)", () => {
|
||||
it("global-only change calls updateGlobalSettings but not updateSettings for global keys", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open the Default Model dropdown and select a model
|
||||
const user = userEvent.setup();
|
||||
const dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
|
||||
// Select a model from the dropdown
|
||||
const option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// updateSettings should NOT be called with global keys
|
||||
const updateSettingsCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const globalSettingsCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
|
||||
// Verify global settings was called with defaultProvider/defaultModelId
|
||||
expect(globalSettingsCalls.length).toBeGreaterThan(0);
|
||||
const globalPayload = globalSettingsCalls[globalSettingsCalls.length - 1][0];
|
||||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||||
|
||||
// Verify project settings was NOT called with global keys (or check it doesn't contain them)
|
||||
if (updateSettingsCalls.length > 0) {
|
||||
const projectPayload = updateSettingsCalls[updateSettingsCalls.length - 1][0];
|
||||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("project-only change calls updateSettings but not updateGlobalSettings for project keys", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open Planning Model dropdown
|
||||
const user = userEvent.setup();
|
||||
const dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||||
await user.click(dropdownBtn);
|
||||
|
||||
// Select a model
|
||||
const option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// updateGlobalSettings should NOT be called with planning keys
|
||||
const updateGlobalSettingsCalls = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const updateSettingsCalls = (updateSettings as ReturnType<typeof vi.fn>).mock.calls;
|
||||
|
||||
// Verify project settings was called with planningProvider/planningModelId
|
||||
expect(updateSettingsCalls.length).toBeGreaterThan(0);
|
||||
const projectPayload = updateSettingsCalls[updateSettingsCalls.length - 1][0];
|
||||
expect(projectPayload).toHaveProperty("planningProvider");
|
||||
expect(projectPayload).toHaveProperty("planningModelId");
|
||||
|
||||
// Verify global settings was NOT called with planning keys
|
||||
if (updateGlobalSettingsCalls.length > 0) {
|
||||
const globalPayload = updateGlobalSettingsCalls[updateGlobalSettingsCalls.length - 1][0];
|
||||
expect(globalPayload.planningProvider).toBeUndefined();
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("mixed global and project changes call both endpoints with correct subsets", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Change global model
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
let user = userEvent.setup();
|
||||
let dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
let option = await screen.findByRole("option", { name: /gpt-4o/i });
|
||||
await user.click(option);
|
||||
|
||||
// Change project model
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
dropdownBtn = screen.getByRole("button", { name: /planning model/i });
|
||||
await user.click(dropdownBtn);
|
||||
option = await screen.findByRole("option", { name: /claude/i });
|
||||
await user.click(option);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
|
||||
// Verify global payload contains only global keys
|
||||
expect(globalPayload).toHaveProperty("defaultProvider");
|
||||
expect(globalPayload).toHaveProperty("defaultModelId");
|
||||
expect(globalPayload.planningProvider).toBeUndefined();
|
||||
expect(globalPayload.planningModelId).toBeUndefined();
|
||||
|
||||
// Verify project payload contains only project keys
|
||||
expect(projectPayload).toHaveProperty("planningProvider");
|
||||
expect(projectPayload).toHaveProperty("planningModelId");
|
||||
expect(projectPayload.defaultProvider).toBeUndefined();
|
||||
expect(projectPayload.defaultModelId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {
|
||||
it("resetting a project override sends null to delete it", async () => {
|
||||
// Mock fetchSettingsByScope to return project with override
|
||||
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
global: { themeMode: "dark", planningGlobalProvider: "anthropic", planningGlobalModelId: "claude-sonnet-4-5" },
|
||||
project: { planningProvider: "openai", planningModelId: "gpt-4o" },
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
await waitFor(() => expect(fetchSettingsByScope).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Project Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Should show Override badge and Reset button
|
||||
const planningLabel = screen.getByLabelText("Planning Model").closest(".form-group");
|
||||
expect(planningLabel?.textContent).toContain("Override (Project)");
|
||||
|
||||
// Click Reset button - find the button with "Reset" text within the planning lane
|
||||
const resetBtn = screen.getByRole("button", { name: "Reset" });
|
||||
expect(resetBtn).toBeTruthy();
|
||||
fireEvent.click(resetBtn);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify null-as-delete: planningProvider and planningModelId should be null (not undefined)
|
||||
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(projectPayload.planningProvider).toBeNull();
|
||||
expect(projectPayload.planningModelId).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing a global setting sends null to delete it", async () => {
|
||||
// Set up initial global settings with defaultProvider/defaultModelId set
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Models")[0]);
|
||||
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
|
||||
|
||||
// Open the Default Model dropdown
|
||||
const user = userEvent.setup();
|
||||
const dropdownBtn = screen.getByRole("button", { name: /default model/i });
|
||||
await user.click(dropdownBtn);
|
||||
|
||||
// Select "Use default" option to clear
|
||||
const useDefaultOption = await screen.findByRole("option", { name: /use default/i });
|
||||
await user.click(useDefaultOption);
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => {
|
||||
expect(updateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify null-as-delete: defaultProvider and defaultModelId should be null when clearing an existing value
|
||||
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(globalPayload.defaultProvider).toBeNull();
|
||||
expect(globalPayload.defaultModelId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user