feat: add AI settings controls
This commit is contained in:
17
tests/ai-create-settings.test.ts
Normal file
17
tests/ai-create-settings.test.ts
Normal 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
35
tests/ai-routes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
38
tests/ui-shell-contract.test.ts
Normal file
38
tests/ui-shell-contract.test.ts
Normal 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
109
tests/user-settings.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user