463 lines
17 KiB
TypeScript
463 lines
17 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice";
|
|
import { SettingsSaveProvider } from "./settings-save-provider";
|
|
import { AppearanceSettings } from "./general-settings";
|
|
|
|
const apiMocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() }));
|
|
const SHOW_STATUS_BAR_LABEL = "Show status bar";
|
|
const ANIMATE_RICH_OUTPUT_CHARTS_LABEL = "Animate rich-output charts";
|
|
const SAVE_CHANGES_LABEL = "Save changes";
|
|
const CHECKED_STATE = "checked";
|
|
const DATA_STATE_ATTRIBUTE = "data-state";
|
|
const DATA_SETTINGS_DIRTY_ATTRIBUTE = "data-settings-dirty";
|
|
const INITIAL_REVISION = 1;
|
|
const OLDER_LIVE_REVISION = 2;
|
|
const SAVED_REVISION = 3;
|
|
const themeMocks = vi.hoisted(() => ({
|
|
previewTheme: vi.fn(),
|
|
commitTheme: vi.fn(),
|
|
restoreTheme: vi.fn(),
|
|
}));
|
|
const storeMocks = vi.hoisted(() => ({
|
|
state: {} as Record<string, unknown>,
|
|
setUserSettings: vi.fn(),
|
|
previewSettingsMenuMode: vi.fn(),
|
|
commitSettingsMenuMode: vi.fn(),
|
|
restoreSettingsMenuMode: vi.fn(),
|
|
previewRichOutputAnimations: vi.fn(),
|
|
previewChatAnimations: vi.fn(),
|
|
commitRichOutputAnimations: vi.fn(),
|
|
commitChatAnimations: vi.fn(),
|
|
restoreRichOutputAnimations: vi.fn(),
|
|
restoreChatAnimations: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
updateUserSettings: (...args: unknown[]) => apiMocks.updateUserSettings(...args),
|
|
}));
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
|
selector(storeMocks.state),
|
|
useAppStoreApi: () => ({ getState: () => storeMocks.state }),
|
|
}));
|
|
|
|
vi.mock("@/components/theme/app-theme", () => ({
|
|
useTheme: () => ({
|
|
savedTheme: "system",
|
|
previewTheme: themeMocks.previewTheme,
|
|
commitTheme: themeMocks.commitTheme,
|
|
restoreTheme: themeMocks.restoreTheme,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/components/settings/language-settings", () => ({ LanguageSettings: () => null }));
|
|
vi.mock("@/components/settings/startup-page-settings-card", () => ({
|
|
StartupPageSettingsCard: () => null,
|
|
}));
|
|
vi.mock("@/components/settings/system-metrics-settings-card", () => ({
|
|
SystemMetricsSettingsCard: () => null,
|
|
}));
|
|
|
|
function renderAppearance() {
|
|
return render(
|
|
<SettingsSaveProvider>
|
|
<AppearanceSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
}
|
|
|
|
function appearanceSettingsResponse(overrides: Record<string, unknown> = {}): {
|
|
settings: Record<string, unknown>;
|
|
} {
|
|
return {
|
|
settings: {
|
|
startup_page: "task_overview",
|
|
changes_panel_layout: "tree",
|
|
app_status_bar_enabled: true,
|
|
system_metrics_display: { show_in_topbar: false, simplified: false },
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function verifyUntouchedExternalAppearanceValueIsNotOverwritten() {
|
|
apiMocks.updateUserSettings.mockResolvedValue(
|
|
appearanceSettingsResponse({
|
|
app_status_bar_enabled: false,
|
|
changes_panel_layout: "flat",
|
|
}),
|
|
);
|
|
const view = renderAppearance();
|
|
|
|
fireEvent.click(screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }));
|
|
storeMocks.state = {
|
|
...storeMocks.state,
|
|
userSettings: {
|
|
...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings),
|
|
changesPanelLayout: "flat",
|
|
},
|
|
};
|
|
view.rerender(
|
|
<SettingsSaveProvider>
|
|
<AppearanceSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
|
|
await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce());
|
|
const payload = apiMocks.updateUserSettings.mock.calls[0]?.[0];
|
|
expect(payload).toMatchObject({ app_status_bar_enabled: false });
|
|
expect(payload).not.toHaveProperty("changes_panel_layout");
|
|
}
|
|
|
|
async function verifyNewerLiveUpdateWinsOverOlderSaveResponse() {
|
|
let resolveSave: (value: ReturnType<typeof appearanceSettingsResponse>) => void = () => {};
|
|
apiMocks.updateUserSettings.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveSave = resolve;
|
|
}),
|
|
);
|
|
storeMocks.state = {
|
|
...storeMocks.state,
|
|
userSettings: {
|
|
...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings),
|
|
revision: INITIAL_REVISION,
|
|
},
|
|
};
|
|
const view = renderAppearance();
|
|
|
|
fireEvent.click(screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }));
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce());
|
|
|
|
storeMocks.state = {
|
|
...storeMocks.state,
|
|
userSettings: {
|
|
...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings),
|
|
appStatusBarEnabled: true,
|
|
revision: SAVED_REVISION,
|
|
},
|
|
};
|
|
view.rerender(
|
|
<SettingsSaveProvider>
|
|
<AppearanceSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
resolveSave(
|
|
appearanceSettingsResponse({
|
|
app_status_bar_enabled: false,
|
|
revision: OLDER_LIVE_REVISION,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce());
|
|
expect(storeMocks.setUserSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ appStatusBarEnabled: true }),
|
|
);
|
|
}
|
|
|
|
async function verifyEditDuringSaveSurvivesItsLiveUpdate() {
|
|
let resolveSave: (value: ReturnType<typeof appearanceSettingsResponse>) => void = () => {};
|
|
apiMocks.updateUserSettings.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveSave = resolve;
|
|
}),
|
|
);
|
|
const view = renderAppearance();
|
|
const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL });
|
|
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce());
|
|
|
|
fireEvent.click(toggle);
|
|
storeMocks.state = {
|
|
...storeMocks.state,
|
|
userSettings: {
|
|
...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings),
|
|
appStatusBarEnabled: false,
|
|
},
|
|
};
|
|
view.rerender(
|
|
<SettingsSaveProvider>
|
|
<AppearanceSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
|
|
await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE));
|
|
resolveSave(appearanceSettingsResponse({ app_status_bar_enabled: false }));
|
|
await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce());
|
|
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
|
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
|
}
|
|
|
|
async function verifyNewerSaveResponseWinsOverOlderLiveUpdate() {
|
|
let resolveSave: (value: ReturnType<typeof appearanceSettingsResponse>) => void = () => {};
|
|
apiMocks.updateUserSettings.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveSave = resolve;
|
|
}),
|
|
);
|
|
storeMocks.state = {
|
|
...storeMocks.state,
|
|
userSettings: {
|
|
...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings),
|
|
revision: INITIAL_REVISION,
|
|
},
|
|
};
|
|
const view = renderAppearance();
|
|
|
|
fireEvent.click(screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }));
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce());
|
|
|
|
storeMocks.state = {
|
|
...storeMocks.state,
|
|
userSettings: {
|
|
...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings),
|
|
appStatusBarEnabled: true,
|
|
revision: OLDER_LIVE_REVISION,
|
|
},
|
|
};
|
|
view.rerender(
|
|
<SettingsSaveProvider>
|
|
<AppearanceSettings />
|
|
</SettingsSaveProvider>,
|
|
);
|
|
resolveSave(
|
|
appearanceSettingsResponse({
|
|
app_status_bar_enabled: false,
|
|
revision: SAVED_REVISION,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce());
|
|
expect(storeMocks.setUserSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ appStatusBarEnabled: false, revision: SAVED_REVISION }),
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
apiMocks.updateUserSettings.mockReset();
|
|
storeMocks.setUserSettings.mockReset();
|
|
themeMocks.previewTheme.mockReset();
|
|
themeMocks.commitTheme.mockReset();
|
|
themeMocks.restoreTheme.mockReset();
|
|
storeMocks.previewSettingsMenuMode.mockReset();
|
|
storeMocks.commitSettingsMenuMode.mockReset();
|
|
storeMocks.restoreSettingsMenuMode.mockReset();
|
|
storeMocks.previewRichOutputAnimations.mockReset();
|
|
storeMocks.previewChatAnimations.mockReset();
|
|
storeMocks.commitRichOutputAnimations.mockReset();
|
|
storeMocks.commitChatAnimations.mockReset();
|
|
storeMocks.restoreRichOutputAnimations.mockReset();
|
|
storeMocks.restoreChatAnimations.mockReset();
|
|
storeMocks.state = {
|
|
userSettings: {
|
|
...defaultSettingsState.userSettings,
|
|
appStatusBarEnabled: true,
|
|
},
|
|
settingsMenu: { savedMode: "flat" },
|
|
richOutputMotion: { enabled: true, savedEnabled: true },
|
|
chatMotion: { enabled: true, savedEnabled: true },
|
|
setUserSettings: storeMocks.setUserSettings,
|
|
previewSettingsMenuMode: storeMocks.previewSettingsMenuMode,
|
|
commitSettingsMenuMode: storeMocks.commitSettingsMenuMode,
|
|
restoreSettingsMenuMode: storeMocks.restoreSettingsMenuMode,
|
|
previewRichOutputAnimations: storeMocks.previewRichOutputAnimations,
|
|
previewChatAnimations: storeMocks.previewChatAnimations,
|
|
commitRichOutputAnimations: storeMocks.commitRichOutputAnimations,
|
|
commitChatAnimations: storeMocks.commitChatAnimations,
|
|
restoreRichOutputAnimations: storeMocks.restoreRichOutputAnimations,
|
|
restoreChatAnimations: storeMocks.restoreChatAnimations,
|
|
};
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("AppearanceSettings rich-output motion preference", () => {
|
|
it("previews and saves chart motion locally without a user-settings request", async () => {
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: ANIMATE_RICH_OUTPUT_CHARTS_LABEL });
|
|
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
|
|
|
fireEvent.click(toggle);
|
|
expect(storeMocks.previewRichOutputAnimations).toHaveBeenCalledWith(false);
|
|
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
|
|
await waitFor(() => expect(storeMocks.commitRichOutputAnimations).toHaveBeenCalledWith(false));
|
|
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
|
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores the saved chart motion preference through Reset", async () => {
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: ANIMATE_RICH_OUTPUT_CHARTS_LABEL });
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
|
|
|
|
await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE));
|
|
expect(storeMocks.restoreRichOutputAnimations).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("AppearanceSettings chat motion preference", () => {
|
|
it("previews and saves chat motion locally without a user-settings request", async () => {
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: "Chat animations" });
|
|
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
|
|
|
fireEvent.click(toggle);
|
|
expect(storeMocks.previewChatAnimations).toHaveBeenCalledWith(false);
|
|
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
|
|
await waitFor(() => expect(storeMocks.commitChatAnimations).toHaveBeenCalledWith(false));
|
|
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
|
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores the saved chat motion preference through Reset", async () => {
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: "Chat animations" });
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
|
|
|
|
await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE));
|
|
expect(storeMocks.restoreChatAnimations).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("AppearanceSettings status bar preference", () => {
|
|
it("saves local-only Appearance changes without a user-settings request", async () => {
|
|
renderAppearance();
|
|
|
|
fireEvent.click(screen.getByTestId("settings-menu-mode-accordion"));
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
|
|
await waitFor(() =>
|
|
expect(storeMocks.commitSettingsMenuMode).toHaveBeenCalledWith("accordion"),
|
|
);
|
|
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
|
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("settings-floating-save").getAttribute("data-status")).toBe(
|
|
"saved",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("saves through the shared appearance action without optimistic store mutation", async () => {
|
|
apiMocks.updateUserSettings.mockResolvedValue(
|
|
appearanceSettingsResponse({ app_status_bar_enabled: false }),
|
|
);
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL });
|
|
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE);
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
|
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
|
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
|
|
await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce());
|
|
expect(apiMocks.updateUserSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ app_status_bar_enabled: false }),
|
|
);
|
|
expect(storeMocks.setUserSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ appStatusBarEnabled: false }),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("settings-floating-save").getAttribute("data-status")).toBe(
|
|
"saved",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("restores the saved preference through Reset", async () => {
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL });
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
|
|
|
|
await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE));
|
|
expect(apiMocks.updateUserSettings).not.toHaveBeenCalled();
|
|
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the draft dirty and confirmed state unchanged after a failed save", async () => {
|
|
apiMocks.updateUserSettings.mockRejectedValueOnce(new Error("offline"));
|
|
renderAppearance();
|
|
|
|
const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL });
|
|
fireEvent.click(toggle);
|
|
fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
|
|
expect(await screen.findByText("Couldn't save")).toBeTruthy();
|
|
expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe("unchecked");
|
|
expect(toggle.getAttribute(DATA_SETTINGS_DIRTY_ATTRIBUTE)).toBe("true");
|
|
expect(storeMocks.setUserSettings).not.toHaveBeenCalled();
|
|
expect(
|
|
(storeMocks.state.userSettings as { appStatusBarEnabled: boolean }).appStatusBarEnabled,
|
|
).toBe(true);
|
|
});
|
|
|
|
it(
|
|
"does not overwrite a newer untouched Appearance value from another client",
|
|
verifyUntouchedExternalAppearanceValueIsNotOverwritten,
|
|
);
|
|
|
|
it(
|
|
"does not replace a newer live update when an older save response finishes",
|
|
verifyNewerLiveUpdateWinsOverOlderSaveResponse,
|
|
);
|
|
|
|
it(
|
|
"keeps an edit made during save when the submitted value arrives live",
|
|
verifyEditDuringSaveSurvivesItsLiveUpdate,
|
|
);
|
|
|
|
it(
|
|
"applies a newer save response after an older live update",
|
|
verifyNewerSaveResponseWinsOverOlderLiveUpdate,
|
|
);
|
|
});
|
|
|
|
it("validates hover drafts, preserves them after a failed save, and discards", async () => {
|
|
renderAppearance();
|
|
const delay = screen.getByRole("spinbutton", { name: "Hover delay (ms)" });
|
|
fireEvent.change(delay, { target: { value: "" } });
|
|
expect(
|
|
(screen.getByRole("button", { name: SAVE_CHANGES_LABEL }) as HTMLButtonElement).disabled,
|
|
).toBe(true);
|
|
fireEvent.change(delay, { target: { value: "750" } });
|
|
apiMocks.updateUserSettings.mockRejectedValueOnce(new Error("Save failed"));
|
|
fireEvent.click(screen.getByRole("button", { name: SAVE_CHANGES_LABEL }));
|
|
await waitFor(() =>
|
|
expect(apiMocks.updateUserSettings).toHaveBeenCalledWith({ sidebar_hover_delay_ms: 750 }),
|
|
);
|
|
await waitFor(() =>
|
|
expect((screen.getByRole("button", { name: "Retry save" }) as HTMLButtonElement).disabled).toBe(
|
|
false,
|
|
),
|
|
);
|
|
expect((delay as HTMLInputElement).value).toBe("750");
|
|
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
|
|
expect((delay as HTMLInputElement).value).toBe("500");
|
|
});
|