import { describe, expect, it } from "vitest"; import { type DashboardConfiguration, type DashboardNavigationModule, resolveDashboardNavigation, } from "../src/dashboard/index.js"; function module(id: string, name = id): DashboardNavigationModule { return { id, name }; } function configuration( groups: ReadonlyArray<{ id: string; label: string; sortOrder: number }>, placements: ReadonlyArray<{ moduleId: string; groupId: string | null; sortOrder: number }>, ): DashboardConfiguration { return { groups, placements }; } const media = { id: "media", label: "Media", sortOrder: 10 }; const planning = { id: "planning", label: "Planning", sortOrder: 20 }; describe("resolving Dashboard navigation", () => { it("groups modules exactly where the configuration places them", () => { const view = resolveDashboardNavigation( configuration( [media, planning], [ { groupId: "media", moduleId: "livestream", sortOrder: 20 }, { groupId: "media", moduleId: "video", sortOrder: 10 }, { groupId: "planning", moduleId: "calendar", sortOrder: 10 }, ], ), [ module("calendar", "Calendar"), module("livestream", "Livestream"), module("video", "Video"), ], ); expect( view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]), ).toEqual([ ["Media", ["Video", "Livestream"]], ["Planning", ["Calendar"]], ]); expect(view.ungrouped).toEqual([]); }); it("shows a newly discovered module without any stored placement", () => { // The rule that makes installing a module sufficient to see it: no initialization write, no // manual database edit, no migration. const view = resolveDashboardNavigation( configuration([media], [{ groupId: "media", moduleId: "video", sortOrder: 10 }]), [module("video", "Video"), module("roster", "Roster")], ); expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]); expect(view.ungrouped.map((entry) => entry.id)).toEqual(["roster"]); }); it("keeps a deliberately ungrouped module distinct from an unconfigured one, and orders both", () => { const view = resolveDashboardNavigation( configuration( [], [ { groupId: null, moduleId: "video", sortOrder: 20 }, { groupId: null, moduleId: "calendar", sortOrder: 10 }, ], ), [module("roster"), module("video"), module("calendar")], ); // Placed-and-ungrouped first, in the administrator's order; never-configured after, by id — so a // newcomer never interrupts an ordering someone chose. expect(view.ungrouped.map((entry) => entry.id)).toEqual(["calendar", "video", "roster"]); }); it("omits a stored placement whose module is not installed", () => { const view = resolveDashboardNavigation( configuration( [media], [ { groupId: "media", moduleId: "video", sortOrder: 10 }, { groupId: "media", moduleId: "uninstalled", sortOrder: 20 }, ], ), [module("video", "Video")], ); // No dead entry renders, and the caller's configuration is untouched — the record survives for a // reinstall. expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]); expect(view.ungrouped).toEqual([]); }); it("restores a reinstalled module to the position its retained placement describes", () => { const stored = configuration( [media], [ { groupId: "media", moduleId: "livestream", sortOrder: 5 }, { groupId: "media", moduleId: "video", sortOrder: 10 }, ], ); const whileUninstalled = resolveDashboardNavigation(stored, [module("video", "Video")]); const afterReinstall = resolveDashboardNavigation(stored, [ module("video", "Video"), module("livestream", "Livestream"), ]); expect(whileUninstalled.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]); expect(afterReinstall.groups[0]?.modules.map((entry) => entry.id)).toEqual([ "livestream", "video", ]); }); it("keeps an empty group rather than hiding it", () => { const view = resolveDashboardNavigation(configuration([media, planning], []), []); expect(view.groups.map((group) => group.id)).toEqual(["media", "planning"]); expect(view.groups.every((group) => group.modules.length === 0)).toBe(true); }); it("treats a placement naming a missing group as ungrouped rather than losing the module", () => { // The schema's `on delete set null` makes this unreachable in practice; the resolver still // refuses to let a configuration inconsistency make a module invisible. const view = resolveDashboardNavigation( configuration([], [{ groupId: "deleted-group", moduleId: "video", sortOrder: 10 }]), [module("video", "Video")], ); expect(view.groups).toEqual([]); expect(view.ungrouped.map((entry) => entry.id)).toEqual(["video"]); }); it("orders deterministically when order values collide", () => { const collided = configuration( [ { id: "zebra", label: "Zebra", sortOrder: 10 }, { id: "alpha", label: "Alpha", sortOrder: 10 }, ], [ { groupId: "alpha", moduleId: "video", sortOrder: 0 }, { groupId: "alpha", moduleId: "calendar", sortOrder: 0 }, { groupId: "alpha", moduleId: "roster", sortOrder: 0 }, ], ); const modules = [module("roster"), module("video"), module("calendar")]; const first = resolveDashboardNavigation(collided, modules); // A different input order must not change the output: the tie-break is the identifier, never // arrival order. const second = resolveDashboardNavigation(collided, [...modules].reverse()); expect(first.groups.map((group) => group.id)).toEqual(["alpha", "zebra"]); expect(first.groups[0]?.modules.map((entry) => entry.id)).toEqual([ "calendar", "roster", "video", ]); expect(JSON.stringify(second)).toBe(JSON.stringify(first)); }); it("is empty and usable with no configuration and no modules", () => { const view = resolveDashboardNavigation(configuration([], []), []); expect(view.groups).toEqual([]); expect(view.ungrouped).toEqual([]); }); it("returns a frozen view no caller can mutate", () => { const view = resolveDashboardNavigation( configuration([media], [{ groupId: "media", moduleId: "video", sortOrder: 10 }]), [module("video", "Video")], ); expect(Object.isFrozen(view)).toBe(true); expect(Object.isFrozen(view.groups)).toBe(true); expect(Object.isFrozen(view.groups[0])).toBe(true); expect(Object.isFrozen(view.ungrouped)).toBe(true); }); }); describe("Dashboard grouping is presentation and nothing else", () => { const modules = [ module("livestream", "Livestream"), module("video", "Video"), module("calendar", "Calendar"), ]; it("creates no module, no parent, and no relationship from a shared group", () => { const view = resolveDashboardNavigation( configuration( [media], [ { groupId: "media", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "video", sortOrder: 20 }, ], ), modules, ); // "Media" is a label on a rendering bucket. It is not a module: nothing in the view carries a // module id, version, artifact, or API root for it, and no entry names another module. const rendered = JSON.parse(JSON.stringify(view)) as unknown; expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["livestream", "video"]); expect(modules.map((entry) => entry.id)).not.toContain("media"); for (const entry of view.groups.flatMap((group) => group.modules)) { // A navigation entry describes exactly one module and never references a second one. expect(Object.keys(entry).sort()).toEqual(["id", "name"]); } expect(JSON.stringify(rendered)).not.toContain("parent"); expect(JSON.stringify(rendered)).not.toContain("depends"); }); it("changes only placement when a module moves to a different group", () => { const before = resolveDashboardNavigation( configuration( [media], [ { groupId: "media", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "video", sortOrder: 20 }, ], ), modules, ); const after = resolveDashboardNavigation( configuration( [media, { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }], [ { groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "video", sortOrder: 20 }, ], ), modules, ); const findLivestream = (view: typeof before) => [...view.groups.flatMap((group) => group.modules), ...view.ungrouped].find( (entry) => entry.id === "livestream", ); // The module object itself is identical across the move: same identity, same name, and — in the // API layer above this — the same embedded mount descriptor. expect(findLivestream(after)).toEqual(findLivestream(before)); expect(before.groups.find((group) => group.id === "media")?.modules.map((m) => m.id)).toEqual([ "livestream", "video", ]); expect(after.groups.find((group) => group.id === "media")?.modules.map((m) => m.id)).toEqual([ "video", ]); expect( after.groups.find((group) => group.id === "sunday-production")?.modules.map((m) => m.id), ).toEqual(["livestream"]); }); it("renders the same module set whatever the arrangement", () => { const arrangements: readonly DashboardConfiguration[] = [ configuration([], []), configuration([media], [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }]), configuration( [media, planning], [ { groupId: "planning", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "calendar", sortOrder: 10 }, { groupId: null, moduleId: "video", sortOrder: 10 }, ], ), ]; for (const arrangement of arrangements) { const view = resolveDashboardNavigation(arrangement, modules); const rendered = [...view.groups.flatMap((group) => group.modules), ...view.ungrouped] .map((entry) => entry.id) .sort(); // Every module is rendered exactly once, in every arrangement. Grouping can never add, // remove, duplicate, or hide a module. expect(rendered).toEqual(["calendar", "livestream", "video"]); } }); });