feat: add AI settings controls

This commit is contained in:
2026-04-10 15:40:29 -04:00
parent e01a7ed1ad
commit 12849b2362
16 changed files with 907 additions and 127 deletions

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { getAiCreateOutcome } from "@/lib/ai-create-flow";
describe("AI create flow settings", () => {
test("single AI event stays in review flow when skip-review is disabled", () => {
expect(getAiCreateOutcome(1, false)).toBe("review-single");
});
test("single AI event is created directly when skip-review is enabled", () => {
expect(getAiCreateOutcome(1, true)).toBe("create-single");
});
test("multi-event AI responses always create directly regardless of skip-review", () => {
expect(getAiCreateOutcome(2, false)).toBe("create-multiple");
expect(getAiCreateOutcome(3, true)).toBe("create-multiple");
});
});

35
tests/ai-routes.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import {
getAiDisabledMessage,
isAdminAiEnabled,
isAiFlagEnabled,
isClientAiEnabled,
} from "@/lib/ai-feature-flags";
describe("AI feature flags", () => {
test("AI admin flag defaults to enabled when unset", () => {
expect(isAdminAiEnabled({})).toBe(true);
});
test("AI admin flag disables routes when explicitly false", () => {
expect(isAdminAiEnabled({ AI_ENABLED: "false" })).toBe(false);
expect(isAdminAiEnabled({ AI_ENABLED: "0" })).toBe(false);
});
test("AI client flag follows NEXT_PUBLIC_AI_ENABLED before server fallback", () => {
expect(
isClientAiEnabled({ NEXT_PUBLIC_AI_ENABLED: "false", AI_ENABLED: "true" }),
).toBe(false);
});
test("truthy and falsy parsing remains explicit and predictable", () => {
expect(isAiFlagEnabled("yes")).toBe(true);
expect(isAiFlagEnabled("off")).toBe(false);
expect(isAiFlagEnabled("unexpected-value")).toBe(true);
});
test("disabled message explains the admin-controlled state", () => {
expect(getAiDisabledMessage().toLowerCase()).toContain("disabled");
expect(getAiDisabledMessage().toLowerCase()).toContain("administrator");
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { getAiDisabledMessage } from "@/lib/ai-feature-flags";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
@@ -35,6 +36,9 @@ const LOCKED_AI_CTA_CLASSES =
const LOCKED_AI_TEXT_CLASSES =
"text-sm font-medium text-foreground";
const DISABLED_AI_TEXT_CLASSES =
"text-sm leading-relaxed text-muted-foreground";
/** Data zone: neutral surface, clearly secondary to AI zone */
const DATA_ZONE_CLASSES =
"flex items-center gap-2 flex-wrap";
@@ -86,6 +90,23 @@ describe("AI zone locked state CTA (unauthenticated)", () => {
const resolved = cn(LOCKED_AI_TEXT_CLASSES);
expect(resolved).toContain("font-medium");
});
test("locked CTA copy clearly requires signing in", () => {
const copy = "Sign in required to generate event drafts with AI";
expect(copy.toLowerCase()).toContain("sign in");
expect(copy.toLowerCase()).toContain("required");
});
});
describe("AI zone disabled state", () => {
test("disabled AI body text stays muted because it is informative, not a CTA", () => {
const resolved = cn(DISABLED_AI_TEXT_CLASSES);
expect(resolved).toContain("text-muted-foreground");
});
test("admin-disabled copy explains the unavailable state", () => {
expect(getAiDisabledMessage().toLowerCase()).toContain("disabled");
});
});
// ─── Cycle 2: Data zone action buttons ──────────────────────────────────────

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import {
APP_HEADER_SURFACE_CLASSES,
APP_NAV_SURFACE_CLASSES,
APP_SECTION_SURFACE_CLASSES,
getConnectionBadgeClasses,
} from "@/lib/ui-shell-contract";
import { EVENT_CARD_SURFACE_CLASSES } from "@/components/event-card";
describe("app shell surfaces", () => {
test("header, primary sections, and bottom navigation all use shared glass utilities", () => {
expect(APP_HEADER_SURFACE_CLASSES).toMatch(/glass-surface/);
expect(APP_SECTION_SURFACE_CLASSES).toMatch(/glass-panel/);
expect(APP_NAV_SURFACE_CLASSES).toMatch(/glass-surface/);
});
test("section surface keeps responsive padding for mobile and larger breakpoints", () => {
expect(APP_SECTION_SURFACE_CLASSES).toContain("p-4");
expect(APP_SECTION_SURFACE_CLASSES).toContain("sm:p-5");
});
});
describe("event cards", () => {
test("event cards use the shared glass card treatment instead of a one-off surface", () => {
expect(EVENT_CARD_SURFACE_CLASSES).toMatch(/glass-card/);
});
});
describe("connection badge", () => {
test("online-ready badge gets a success treatment while offline stays neutral", () => {
const onlineClasses = getConnectionBadgeClasses(true);
const offlineClasses = getConnectionBadgeClasses(false);
expect(onlineClasses).toMatch(/emerald/);
expect(offlineClasses).not.toMatch(/emerald/);
expect(offlineClasses).toContain("text-muted-foreground");
});
});

109
tests/user-settings.test.ts Normal file
View File

@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test";
import {
USER_SETTINGS_STORAGE_KEY,
getDefaultUserSettings,
loadUserSettings,
saveUserSettings,
type UserSettings,
} from "@/lib/user-settings";
const createStorage = (initialState?: Record<string, string>) => {
const state = { ...initialState };
return {
getItem(key: string) {
return state[key] ?? null;
},
setItem(key: string, value: string) {
state[key] = value;
},
read(key: string) {
return state[key];
},
};
};
const createFailingStorage = () => ({
getItem() {
throw new Error("storage read failed");
},
setItem() {
throw new Error("storage write failed");
},
});
describe("user settings defaults", () => {
test("returns typed defaults for future feature flags", () => {
expect(getDefaultUserSettings()).toEqual({
aiEnabled: true,
skipAiReview: false,
});
});
test("loads defaults when no persisted settings exist yet", () => {
const storage = createStorage();
expect(loadUserSettings(storage)).toEqual(getDefaultUserSettings());
});
test("loads the saved values from shared persisted settings storage", () => {
const savedSettings: UserSettings = {
aiEnabled: false,
skipAiReview: true,
};
const storage = createStorage({
[USER_SETTINGS_STORAGE_KEY]: JSON.stringify(savedSettings),
});
expect(loadUserSettings(storage)).toEqual(savedSettings);
});
test("keeps defaults for missing or malformed saved fields", () => {
const storage = createStorage({
[USER_SETTINGS_STORAGE_KEY]: JSON.stringify({ skipAiReview: true, aiEnabled: "nope" }),
});
expect(loadUserSettings(storage)).toEqual({
aiEnabled: true,
skipAiReview: true,
});
});
test("falls back to defaults when persisted settings are not valid JSON", () => {
const storage = createStorage({
[USER_SETTINGS_STORAGE_KEY]: "{not-json",
});
expect(loadUserSettings(storage)).toEqual(getDefaultUserSettings());
});
test("falls back to defaults when storage cannot be read", () => {
expect(loadUserSettings(createFailingStorage())).toEqual(
getDefaultUserSettings(),
);
});
test("saves a complete settings snapshot under the shared persistence key", () => {
const storage = createStorage();
const nextSettings: UserSettings = {
aiEnabled: false,
skipAiReview: true,
};
expect(saveUserSettings(nextSettings, storage)).toEqual(nextSettings);
expect(storage.read(USER_SETTINGS_STORAGE_KEY)).toBe(
JSON.stringify(nextSettings),
);
});
test("returns the requested settings even when persistence fails", () => {
const nextSettings: UserSettings = {
aiEnabled: false,
skipAiReview: true,
};
expect(saveUserSettings(nextSettings, createFailingStorage())).toEqual(
nextSettings,
);
});
});