333 lines
12 KiB
TypeScript
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([]);
|
|
});
|
|
});
|