213 lines
6.1 KiB
TypeScript
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");
|
|
});
|
|
});
|