import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { __resetLocalStorage, __setPreferenceValues } from "../__mocks__/@vicinae/api"; import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "../lib/constants"; import { clearModelSelection, describeAutoDelete, describeMaxToolRounds, describeResumeTimeout, resolveAutoDelete, resolveMaxToolRounds, resolvePreferences, loadPreferences, readModelSelection, resolveResumeTimeout, selectModel, } from "../lib/preferences"; const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; describe("resolveResumeTimeout", () => { it("maps each configured window", () => { expect(resolveResumeTimeout("15m")).toBe(15 * MINUTE); expect(resolveResumeTimeout("1h")).toBe(HOUR); expect(resolveResumeTimeout("6h")).toBe(6 * HOUR); expect(resolveResumeTimeout("1d")).toBe(DAY); }); it("returns undefined for 'never', which means always reopen the last chat", () => { expect(resolveResumeTimeout("never")).toBeUndefined(); }); it("returns undefined for missing or unrecognised values rather than guessing", () => { expect(resolveResumeTimeout(undefined)).toBeUndefined(); expect(resolveResumeTimeout("")).toBeUndefined(); expect(resolveResumeTimeout("banana")).toBeUndefined(); }); it("tolerates surrounding whitespace", () => { expect(resolveResumeTimeout(" 15m ")).toBe(15 * MINUTE); }); }); describe("describeResumeTimeout", () => { it("names the never case rather than showing a number", () => { expect(describeResumeTimeout(undefined)).toContain("Never"); }); it("describes minutes, hours, and days", () => { expect(describeResumeTimeout(15 * MINUTE)).toBe("After 15 minutes"); expect(describeResumeTimeout(HOUR)).toBe("After 1 hour"); expect(describeResumeTimeout(6 * HOUR)).toBe("After 6 hours"); expect(describeResumeTimeout(DAY)).toBe("After 1 day"); }); }); describe("resolvePreferences", () => { it("fills in every optional value", () => { const resolved = resolvePreferences({}); expect(resolved.model).toBe(DEFAULT_MODEL); expect(resolved.host).toBe(DEFAULT_HOST); expect(resolved.webEnabled).toBe(true); expect(resolved.showToolCalls).toBe(true); expect(resolved.showReasoning).toBe(true); expect(resolved.think).toBeUndefined(); expect(resolved.resumeTimeoutMs).toBe(HOUR); // Auto-deletion is the one setting here that destroys data, so its default // is off and every unresolvable value lands on the same answer. expect(resolved.autoDeleteMs).toBeUndefined(); expect(resolved.maxToolRounds).toBe(DEFAULT_MAX_TOOL_ROUNDS); // An empty string rather than undefined, so callers never branch on it. expect(resolved.place).toBe(""); }); it("leaves IP geolocation off unless it was switched on", () => { // The one setting that sends something about the user to a third party is // opted into, never defaulted into. expect(resolvePreferences({}).ipGeolocation).toBe(false); expect(resolvePreferences({ ipGeolocation: true }).ipGeolocation).toBe(true); expect(resolvePreferences({ ipGeolocation: false }).ipGeolocation).toBe(false); }); it("keeps the configured location, trimmed", () => { expect(resolvePreferences({ location: " Cebu City, Philippines " }).place).toBe( "Cebu City, Philippines", ); }); it("treats a whitespace-only location as unset", () => { expect(resolvePreferences({ location: " " }).place).toBe(""); }); it("honours the display toggles being switched off", () => { const resolved = resolvePreferences({ showToolCalls: false, showReasoning: false }); expect(resolved.showToolCalls).toBe(false); expect(resolved.showReasoning).toBe(false); }); it("treats an unknown model or host as unset", () => { const resolved = resolvePreferences({ model: " ", ollamaHost: "" }); expect(resolved.model).toBe(DEFAULT_MODEL); expect(resolved.host).toBe(DEFAULT_HOST); }); it("keeps a local Ollama host", () => { expect(resolvePreferences({ ollamaHost: "http://localhost:11434" }).host).toBe( "http://localhost:11434", ); }); it("treats reasoning 'off' as disabled", () => { expect(resolvePreferences({ thinking: "off" }).think).toBeUndefined(); expect(resolvePreferences({ thinking: "high" }).think).toBe("high"); }); it("carries the resume window through, including never", () => { expect(resolvePreferences({ resumeTimeout: "15m" }).resumeTimeoutMs).toBe(15 * MINUTE); expect(resolvePreferences({ resumeTimeout: "never" }).resumeTimeoutMs).toBeUndefined(); }); it("carries the auto-delete window through, including disabled", () => { expect(resolvePreferences({ autoDeleteAfter: "30d" }).autoDeleteMs).toBe(30 * DAY); expect(resolvePreferences({ autoDeleteAfter: "disabled" }).autoDeleteMs).toBeUndefined(); expect(resolvePreferences({ autoDeleteAfter: "garbage" }).autoDeleteMs).toBeUndefined(); }); it("carries the tool-call cap through, including unlimited", () => { expect(resolvePreferences({ maxToolRounds: "16" }).maxToolRounds).toBe(16); expect(resolvePreferences({ maxToolRounds: "unlimited" }).maxToolRounds).toBeUndefined(); expect(resolvePreferences({ maxToolRounds: "garbage" }).maxToolRounds).toBe(DEFAULT_MAX_TOOL_ROUNDS); }); }); describe("resolveAutoDelete", () => { it("maps each configured window", () => { expect(resolveAutoDelete("1d")).toBe(DAY); expect(resolveAutoDelete("7d")).toBe(7 * DAY); expect(resolveAutoDelete("30d")).toBe(30 * DAY); expect(resolveAutoDelete("90d")).toBe(90 * DAY); }); it("returns undefined for 'disabled', which is the default", () => { expect(resolveAutoDelete("disabled")).toBeUndefined(); }); it("returns undefined for missing or unrecognised values rather than guessing", () => { // Both readings of an unknown value are safe here only because the fallback // is "delete nothing". See `resolveMaxToolRounds` for the other convention. expect(resolveAutoDelete(undefined)).toBeUndefined(); expect(resolveAutoDelete("")).toBeUndefined(); expect(resolveAutoDelete("banana")).toBeUndefined(); expect(resolveAutoDelete("1y")).toBeUndefined(); }); it("tolerates surrounding whitespace and casing", () => { expect(resolveAutoDelete(" 7D ")).toBe(7 * DAY); }); }); describe("describeAutoDelete", () => { it("names the off case rather than showing a number", () => { expect(describeAutoDelete(undefined)).toContain("Disabled"); }); it("describes days", () => { expect(describeAutoDelete(DAY)).toBe("After 1 day"); expect(describeAutoDelete(30 * DAY)).toBe("After 30 days"); }); }); describe("resolveMaxToolRounds", () => { it("maps each configured cap", () => { expect(resolveMaxToolRounds("4")).toBe(4); expect(resolveMaxToolRounds("8")).toBe(8); expect(resolveMaxToolRounds("16")).toBe(16); }); it("returns undefined for unlimited, which is what removes the cap", () => { expect(resolveMaxToolRounds("unlimited")).toBeUndefined(); }); it("falls back to the default cap for anything unrecognised", () => { // Deliberately the opposite of `resolveAutoDelete`: those two settings are // safe when they do nothing, and this one guards a loop, so the safe reading // of a value this codebase does not understand is the cap and not its absence. expect(resolveMaxToolRounds(undefined)).toBe(DEFAULT_MAX_TOOL_ROUNDS); expect(resolveMaxToolRounds("")).toBe(DEFAULT_MAX_TOOL_ROUNDS); expect(resolveMaxToolRounds("banana")).toBe(DEFAULT_MAX_TOOL_ROUNDS); expect(resolveMaxToolRounds("0")).toBe(DEFAULT_MAX_TOOL_ROUNDS); }); it("tolerates surrounding whitespace and casing", () => { expect(resolveMaxToolRounds(" UNLIMITED ")).toBeUndefined(); }); }); describe("describeMaxToolRounds", () => { it("names the uncapped case", () => { expect(describeMaxToolRounds(undefined)).toContain("Unlimited"); }); it("describes a cap in rounds", () => { expect(describeMaxToolRounds(8)).toBe("8 rounds per answer"); expect(describeMaxToolRounds(1)).toBe("1 round per answer"); }); }); describe("the model selection", () => { beforeEach(() => { __resetLocalStorage(); __setPreferenceValues({ apiKey: "k", model: "from-preference:1b" }); }); afterEach(() => { __setPreferenceValues({}); }); it("uses the preference when nothing has been picked", async () => { const resolved = await loadPreferences(); expect(resolved.model).toBe("from-preference:1b"); expect(resolved.modelSource).toBe("preference"); expect(resolved.preferenceModel).toBe("from-preference:1b"); }); it("prefers a picked model over the preference, and says so", async () => { // The whole reason this exists: an extension cannot write its own // preferences, so the pick has to live somewhere else and win. await selectModel("picked:9b"); const resolved = await loadPreferences(); expect(resolved.model).toBe("picked:9b"); expect(resolved.modelSource).toBe("selection"); // Both values are carried, so a screen can answer "what would a reset give // me back". expect(resolved.preferenceModel).toBe("from-preference:1b"); }); it("round-trips a pick through storage", async () => { await selectModel("first:1b"); await expect(readModelSelection()).resolves.toBe("first:1b"); await selectModel("second:2b"); await expect(readModelSelection()).resolves.toBe("second:2b"); }); it("hands the model back to the preference when the selection is cleared", async () => { await selectModel("picked:9b"); await clearModelSelection(); const resolved = await loadPreferences(); expect(resolved.model).toBe("from-preference:1b"); expect(resolved.modelSource).toBe("preference"); }); it("ignores a stored selection that is empty or whitespace", async () => { // A blank value is not a model name, and treating it as one would leave the // chat asking for "" on every turn. await selectModel(" "); await expect(readModelSelection()).resolves.toBeUndefined(); await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" }); }); it("trims a stored selection", async () => { await selectModel(" spaced:1b "); await expect(readModelSelection()).resolves.toBe("spaced:1b"); }); it("falls back to the preference when storage cannot be read", async () => { const { LocalStorage } = await import("../__mocks__/@vicinae/api"); const original = LocalStorage.getItem; LocalStorage.getItem = async () => { throw new Error("storage is unavailable"); }; try { await expect(loadPreferences()).resolves.toMatchObject({ model: "from-preference:1b" }); } finally { LocalStorage.getItem = original; } }); it("leaves the model source as the preference when resolving without storage", () => { expect(resolvePreferences({ model: "plain:1b" })).toMatchObject({ model: "plain:1b", modelSource: "preference", preferenceModel: "plain:1b", }); }); }); describe("file access preferences", () => { it("is off when nothing says otherwise", () => { // The default matters more than the setting here: an assistant that can read files // should be one the user switched on, because reading is the half that cannot ask // first. expect(resolvePreferences({}).fileEnabled).toBe(false); }); it("is on only when the checkbox is ticked", () => { expect(resolvePreferences({ fileAccess: true }).fileEnabled).toBe(true); expect(resolvePreferences({ fileAccess: false }).fileEnabled).toBe(false); }); it("carries extra roots, and reports the ones it could not use", () => { const resolved = resolvePreferences({ readRoots: "/Volumes/Archive\nrelative/oops", writeRoots: "/Volumes/Archive/scratch", }); expect(resolved.extraReadRoots).toEqual(["/Volumes/Archive"]); expect(resolved.rejectedReadRoots).toEqual(["relative/oops"]); expect(resolved.extraWriteRoots).toEqual(["/Volumes/Archive/scratch"]); expect(resolved.rejectedWriteRoots).toEqual([]); }); it("has no extra roots when the fields are empty", () => { const resolved = resolvePreferences({}); expect(resolved.extraReadRoots).toEqual([]); expect(resolved.extraWriteRoots).toEqual([]); expect(resolved.rejectedReadRoots).toEqual([]); expect(resolved.rejectedWriteRoots).toEqual([]); }); });