feat(FN-1731): merge fusion/fn-1731

This commit is contained in:
gsxdsm
2026-04-15 05:00:56 -07:00
parent c5600372b6
commit 8d5b8cf3df
3 changed files with 651 additions and 1 deletions

View File

@@ -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();
});
});
});