Files
EpicNext-Cms/src/features/housekeeping/foundation/shell/personalization.test.tsx
T

213 lines
6.1 KiB
TypeScript

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
ok,
} from "../contracts";
import { defaultHousekeepingPreferences } from "../preferences/schema";
import {
createHousekeepingRegistry,
loadHousekeepingWidgets,
} from "../registry";
import { Favorites, movePreferenceId, togglePinnedRoute } from "./favorites";
import { RecentWork } from "./recent-work";
import { WidgetGrid } from "./widget-grid";
import {
preferencesAfterSave,
toggleOptionalWidget,
WidgetSettings,
} from "./widget-settings";
const usersView = anyCapability(PERMS.USERS_VIEW);
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_VIEW,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
};
const registry = createHousekeepingRegistry([
{
id: "people",
labelKey: "people",
descriptionKey: "people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: usersView,
routes: [
{
id: "people.users",
labelKey: "Users",
href: "/ase/people/users",
capability: usersView,
},
{
id: "people.tickets",
labelKey: "Tickets",
href: "/ase/people/tickets",
capability: usersView,
},
],
searchProviders: [],
inboxSources: [],
widgets: [
{
id: "people.mandatory",
owner: "people",
capability: usersView,
kind: "mandatory",
load: async () => ok({ count: 1 }, "mandatory"),
},
{
id: "people.optional",
owner: "people",
capability: usersView,
kind: "optional",
load: async () => ok({ count: 2 }, "optional"),
},
{
id: "people.failing",
owner: "people",
capability: usersView,
kind: "optional",
load: async () => fail("TIMEOUT", "timeout", "failing"),
},
],
},
]);
const initialPreferences = {
...defaultHousekeepingPreferences(),
pinnedRouteIds: ["people.users"],
shortcutOrder: ["people.users"],
widgetOrder: ["stale.widget", "people.optional", "people.mandatory"],
enabledOptionalWidgetIds: ["people.optional", "people.failing"],
};
const save = vi.fn(async (value) => ok(value, "saved"));
describe("housekeeping personalization", () => {
it("loads enabled widgets independently in reconciled order and reports partial failures", async () => {
const result = await loadHousekeepingWidgets(
registry,
initialPreferences,
context,
);
expect(result.widgets).toEqual([
{ id: "people.optional", data: { count: 2 } },
{ id: "people.mandatory", data: { count: 1 } },
]);
expect(result.errors).toEqual([
{ widgetId: "people.failing", code: "TIMEOUT" },
]);
});
it("pins, unpins, and reorders only stable preference identifiers", () => {
const pinned = togglePinnedRoute(initialPreferences, "people.tickets");
expect(pinned.pinnedRouteIds).toEqual(["people.users", "people.tickets"]);
expect(pinned.shortcutOrder).toEqual(["people.users", "people.tickets"]);
expect(
movePreferenceId(pinned.shortcutOrder, "people.tickets", "people.users"),
).toEqual(["people.tickets", "people.users"]);
expect(togglePinnedRoute(pinned, "people.users")).toMatchObject({
pinnedRouteIds: ["people.tickets"],
shortcutOrder: ["people.tickets"],
});
});
it("locks mandatory widgets, toggles optional widgets, and retains UI state after a failed save", () => {
const enabled = toggleOptionalWidget(initialPreferences, "people.optional");
expect(enabled.enabledOptionalWidgetIds).toEqual(["people.failing"]);
const failure = fail(
"INTERNAL",
"errors.housekeeping.preferences.save",
"save-failed",
);
expect(preferencesAfterSave(initialPreferences, enabled, failure)).toEqual({
preferences: initialPreferences,
error: "errors.housekeeping.preferences.save",
});
});
it("renders favorites, recent links, locked settings, keyboard ordering, and widget errors accessibly", () => {
const widgets = {
widgets: [
{ id: "people.optional", data: { count: 2 } },
{ id: "people.mandatory", data: { count: 1 } },
],
errors: [{ widgetId: "people.failing", code: "TIMEOUT" as const }],
};
const routes = registry.domains[0]?.routes ?? [];
const definitions = registry.domains[0]?.widgets ?? [];
const html = renderToStaticMarkup(
<>
<Favorites
preferences={initialPreferences}
routes={routes}
savePreferences={save}
labels={{
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}}
/>
<RecentWork
items={[
{
routeId: "people.users",
canonicalHref: "/ase/people/users",
labelKey: "Users",
occurredAt: "2026-08-30T10:00:00.000Z",
source: "route-visit",
},
]}
labels={{ title: "Recent work", empty: "Nothing recent" }}
/>
<WidgetSettings
preferences={initialPreferences}
widgets={definitions}
savePreferences={save}
labels={{
title: "Widget settings",
mandatory: "Required",
error: "Save failed",
}}
/>
<WidgetGrid
preferences={initialPreferences}
result={widgets}
savePreferences={save}
labels={{
title: "Widgets",
partial: "Some widgets failed",
error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}}
/>
</>,
);
expect(html).toContain('href="/ase/people/users"');
expect(html).toContain("Required");
expect(html).toContain('disabled=""');
expect(html).toContain("Some widgets failed");
expect(html).not.toContain("TIMEOUT");
const gridSource = readFileSync(
resolve(
process.cwd(),
"src/features/housekeeping/foundation/shell/widget-grid.tsx",
),
"utf8",
);
expect(gridSource).toContain("KeyboardSensor");
expect(gridSource).toContain("sortableKeyboardCoordinates");
});
});