Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard. - Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe label, kebab-case path relative to the module root; no traversal, schemes, queries, duplicates, or nesting). Validated before activation. - Version 8 stays accepted beside version 9, each read strictly as itself (discriminated union); no released manifest is rewritten. The template moves to v9; the released Signage 0.1.5 manifest is a test fixture. - Real module routes: /<id> is the landing view, /<id>/<page> a declared page, anything beneath a page a module-owned detail route; anything else is not-found and mounts nothing. api/assets/display are never pages. - Sidebar: declared icons, pages nested under an open module, one aria-current, collapsed and drawer behaviour; v8 modules unchanged. - module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe, navigate, link, settings), refusing anything outside the module's routes; undefined on the display host. Mount ABI unchanged. - Return-to-origin: one `?return=` carrier on Settings URLs, validated by parseReturnTarget and against running modules' declared routes; Settings Back returns to exactly the originating module page, across refreshes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
41 lines
1.7 KiB
TypeScript
41 lines
1.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type ModuleUiHostContext,
|
|
type ModuleUiNavigation,
|
|
ModuleUiNavigationError,
|
|
} from "../src/index.js";
|
|
|
|
/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the
|
|
* Dashboard's implementation is proven in `surfaces/shell/tests/module-navigation.test.tsx`. */
|
|
describe("module UI navigation contract", () => {
|
|
it("reports a refused path with a stable code and a fixed message that never echoes the path", () => {
|
|
for (const code of [
|
|
"MODULE_NAVIGATION_PATH_INVALID",
|
|
"MODULE_NAVIGATION_PATH_UNKNOWN",
|
|
] as const) {
|
|
const error = new ModuleUiNavigationError(code);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error.name).toBe("ModuleUiNavigationError");
|
|
expect(error.code).toBe(code);
|
|
expect(error.stack).toBe(`ModuleUiNavigationError: ${error.message}`);
|
|
expect(error.message).not.toMatch(/[/:?#]/u);
|
|
}
|
|
});
|
|
|
|
it("makes navigation a context member a host without pages leaves undefined", () => {
|
|
// A type-level contract: a context may say it has no navigation, as the display host does, and
|
|
// a module reads it through an optional check.
|
|
const describe = (context: Pick<ModuleUiHostContext, "navigation">): string =>
|
|
context.navigation?.location().path ?? "no navigation";
|
|
const navigation: ModuleUiNavigation = {
|
|
link: () => ({ href: "/m/x", onClick: () => undefined }),
|
|
location: () => ({ page: "x", path: "x" }),
|
|
navigate: () => undefined,
|
|
settings: undefined,
|
|
subscribe: () => () => undefined,
|
|
};
|
|
expect(describe({ navigation: undefined })).toBe("no navigation");
|
|
expect(describe({ navigation })).toBe("x");
|
|
});
|
|
});
|