diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts index 6be21eba..3269e8d7 100644 --- a/tests/settings/settings-tab.test.ts +++ b/tests/settings/settings-tab.test.ts @@ -73,17 +73,101 @@ describe("settings tab", () => { expect(declarativeDefinitionByName(definitions, "Codex details")?.searchable).toBe(false); }); - it("routes declarative controls through the existing settings publication boundary", async () => { + it("routes every declarative control through the existing settings publication boundary", async () => { const saveSettings = vi.fn().mockResolvedValue(undefined); const refreshOpenViews = vi.fn(); const tab = newSettingsTab({ saveSettings, refreshOpenViews }); - expect(tab.getControlValue("showToolbar")).toBe(true); - await tab.setControlValue("showToolbar", false); + const changes = [ + ["showToolbar", false], + ["sendShortcut", "mod-enter"], + ["scrollThreadFromComposerEdges", true], + ["referenceActiveNoteOnSend", true], + ["archiveExportEnabled", true], + ] as const; + for (const [key, value] of changes) { + await tab.setControlValue(key, value); + expect(tab.getControlValue(key)).toBe(value); + expect(saveSettings).toHaveBeenLastCalledWith(expect.objectContaining({ [key]: value })); + } - expect(saveSettings).toHaveBeenCalledOnce(); + expect(saveSettings).toHaveBeenCalledTimes(changes.length); expect(refreshOpenViews).toHaveBeenCalledOnce(); - expect(tab.getControlValue("showToolbar")).toBe(false); + }); + + it("ignores invalid declarative control values and rejects unknown keys", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + const tab = newSettingsTab({ saveSettings }); + + await tab.setControlValue("showToolbar", "false"); + await tab.setControlValue("sendShortcut", "invalid"); + await tab.setControlValue("scrollThreadFromComposerEdges", null); + await tab.setControlValue("referenceActiveNoteOnSend", 1); + await tab.setControlValue("archiveExportEnabled", undefined); + + expect(saveSettings).not.toHaveBeenCalled(); + expect(tab.getControlValue("unknown")).toBeUndefined(); + await expect(tab.setControlValue("unknown", true)).rejects.toThrow("Unknown declarative setting key: unknown"); + }); + + it("publishes model and effort changes from a representative declarative helper renderer", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + useShortLivedClients(settingsClient()); + const tab = newSettingsTab({ + saveSettings, + modelsSnapshot: modelMetadataFromCatalogModels([model("gpt-5.5", false, false, ["extreme"])]), + }); + const helper = declarativeDefinitionByName(tab.getSettingDefinitions(), "Automatic thread naming"); + const container = renderDeclarativeDefinition(helper, "Automatic thread naming"); + + const modelSelect = container.querySelectorAll("select")[0]; + if (!modelSelect) throw new Error("Missing declarative helper model dropdown"); + modelSelect.value = "gpt-5.5"; + modelSelect.dispatchEvent(new Event("change")); + await flushPromises(); + + const effortSelect = container.querySelectorAll("select")[1]; + if (!effortSelect) throw new Error("Missing declarative helper effort dropdown"); + effortSelect.value = "extreme"; + effortSelect.dispatchEvent(new Event("change")); + await flushPromises(); + + expect(saveSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ threadNamingModel: "gpt-5.5" })); + expect(saveSettings).toHaveBeenNthCalledWith(2, expect.objectContaining({ threadNamingEffort: "extreme" })); + }); + + it("normalizes and publishes a representative declarative archive text field", async () => { + const saveSettings = vi.fn().mockResolvedValue(undefined); + useShortLivedClients(settingsClient()); + const tab = newSettingsTab({ saveSettings }); + const filename = declarativeDefinitionByName(tab.getSettingDefinitions(), "Saved note filename"); + const container = renderDeclarativeDefinition(filename, "Saved note filename"); + const input = container.querySelector("input"); + if (!input) throw new Error("Missing declarative archive filename input"); + + input.value = " {{date}} {{title}}.md "; + input.dispatchEvent(new Event("blur")); + await flushPromises(); + + expect(saveSettings).toHaveBeenCalledWith(expect.objectContaining({ archiveExportFilenameTemplate: "{{date}} {{title}}.md" })); + expect(input.value).toBe("{{date}} {{title}}.md"); + }); + + it("restores a thread through the declarative archived-section callback", async () => { + const client = settingsClient(); + useShortLivedClients(client); + const tab = newSettingsTab({ + archivedSnapshot: [panelThread({ id: "thread-archived", preview: "Archived thread", archived: true })], + }); + const archived = declarativeDefinitionByName(tab.getSettingDefinitions(), "Archived threads content"); + const container = renderDeclarativeDefinition(archived, "Archived threads content"); + const restore = container.querySelector('[aria-label="Restore thread"]'); + if (!restore) throw new Error("Missing declarative archived-thread restore action"); + + restore.click(); + await flushPromises(); + + expect(client.request).toHaveBeenCalledWith("thread/unarchive", { threadId: "thread-archived" }); }); it("starts dynamic loading from declarative rendering instead of definition indexing", async () => { @@ -786,6 +870,13 @@ function declarativeDefinitionByName( return undefined; } +function renderDeclarativeDefinition(definition: DeclarativeSettingDefinition | undefined, name: string): HTMLDivElement { + if (!definition?.render) throw new Error(`Missing declarative ${name} renderer`); + const container = document.createElement("div"); + definition.render(new Setting(container), {} as never); + return container; +} + function settingNames(tab: CodexPanelSettingTab): string[] { return Array.from(settingsSectionRoots(tab)).flatMap((element) => { if (element.classList.contains("codex-panel-settings__header")) return [];