Files
lam-agent-vicinae/src/__tests__/preferences.test.ts
T

333 lines
12 KiB
TypeScript

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