Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Media") at /catalog 1`] = `
{
"Media": true,
"Station": false,
"Status": false,
"Tools": false,
}
`;

exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Station") at /ads 1`] = `
{
"Media": false,
"Station": true,
"Status": false,
"Tools": false,
}
`;

exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Status") at /health 1`] = `
{
"Media": false,
"Station": false,
"Status": true,
"Tools": false,
}
`;

exports[`Feature: Grouped navigation — F203.6 snapshot shows exactly one open group ("Tools") at /gardener 1`] = `
{
"Media": false,
"Station": false,
"Status": false,
"Tools": true,
}
`;
177 changes: 170 additions & 7 deletions admin-ui/__specs__/about-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,185 @@
//
// BDD specification — Jest. RED at plan time: every specification is it.todo — turn it into an `it` only in the
// task that makes it green (T561). Each Given comment names the arrange the scenario needs.
//
// next/jest's SWC transform (unlike babel-jest) does not hoist jest.mock() calls above import
// statements (mirrors app-shell.spec.tsx's own header comment), so Sidebar — which calls the
// mocked next/navigation hook — is loaded via a dynamic `await import()` inside the test.

jest.mock("next/navigation", () => ({
usePathname: jest.fn(),
}));

jest.mock("@/app/login/actions", () => ({
logout: jest.fn(),
}));

import { describe, it, jest, expect } from "@jest/globals";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/jest-globals";
import type { usePathname } from "next/navigation";
import { AboutView } from "../app/(authed)/about/AboutView";
import type { AboutResponseDto } from "../lib/about-api";

const mockedUsePathname = jest
.requireMock<{ usePathname: typeof usePathname }>("next/navigation")
.usePathname as jest.MockedFunction<typeof usePathname>;

import { describe, it } from "@jest/globals";
// Given: a single fixture GET /api/about response — two attribution kinds, two packs, so AC4's
// "shows every attribution name" fact can't pass vacuously on a one-item list.
const ABOUT_FIXTURE: AboutResponseDto = {
version: "5.11.0+abc123",
stationName: "GWAV 108.8",
tagline: "Your late-night companion",
libraryCount: 4821,
uptimeSeconds: 90_000, // 1 day, 1 hour
attributions: {
groups: [
{
kind: "jingle-pack",
packs: [
{
slug: "gw-first-beds",
name: "GW First Beds",
attributions: [
{
title: "Night Drive",
creator: "Jane Doe",
sourceUrl: "https://example.com/night-drive",
license: "CC-BY",
},
],
},
],
},
{
kind: "font-pack",
packs: [
{
slug: "about-font-pack",
name: "About Grotesk",
attributions: [
{
title: "Grotesk Typeface",
creator: null,
sourceUrl: "https://fonts.example.com/about-grotesk",
license: "OFL-1.1",
},
],
},
],
},
],
},
};

describe("Feature: About page", () => {
describe("Scenario: the page with a response", () => {
// Given: /about rendered with version, stationName, libraryCount, uptimeSeconds, attributions
it.todo("AC4 — shows the version");
it.todo("AC4 — shows the station name");
it.todo("AC4 — shows the library count");
it.todo("AC4 — shows the uptime");
it.todo("AC4 — shows every attribution name");
it("AC4 — shows the version", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getByText(/5\.11\.0\+abc123/)).toBeInTheDocument();
});

it("AC4 — shows the station name", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getByText("GWAV 108.8")).toBeInTheDocument();
});

it("AC4 — shows the library count", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getByText(/4821/)).toBeInTheDocument();
});

it("AC4 — shows the uptime", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getByText(/1 day, 1 hour/)).toBeInTheDocument();
});

// One assertion: the rendered pack-name elements' own text, collected in document order,
// against the expected list — catches a missing OR an extra pack, not just "Night Drive is
// somewhere" (that string is also an attribution-line title, so a getByText check on it
// wasn't even proving the PACK name rendered at all).
it("AC4 — shows every attribution name", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getAllByTestId("attribution-pack-name").map((el) => el.textContent)).toEqual([
"GW First Beds",
"About Grotesk",
]);
});
});

describe("Scenario: the tagline", () => {
// Given: Station:Tagline is set (Dean's ruling 2026-09-23)
it("renders the tagline when set", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getByText("Your late-night companion")).toBeInTheDocument();
});

// Given: Station:Tagline is unset ("" on the wire)
it("is absent when the tagline is blank", () => {
render(<AboutView about={{ ...ABOUT_FIXTURE, tagline: "" }} />);
expect(screen.queryByText("Your late-night companion")).not.toBeInTheDocument();
});

// Given: Station:Tagline is whitespace-only (a settings-form edge case, not merely "")
it("is absent when the tagline is whitespace-only", () => {
render(<AboutView about={{ ...ABOUT_FIXTURE, tagline: " " }} />);
expect(screen.queryByTestId("station-tagline")).not.toBeInTheDocument();
});
});

describe("Scenario: attribution source links", () => {
// Given: an attribution line whose sourceUrl is http(s) — a safe link target
it("links the credit when sourceUrl is http(s)", () => {
render(<AboutView about={ABOUT_FIXTURE} />);
expect(screen.getByRole("link", { name: "Night Drive" })).toHaveAttribute(
"href",
"https://example.com/night-drive",
);
});

// Given: an attribution line whose sourceUrl is NOT http(s) — untrusted pack-manifest data,
// never pointed at from an href (security-web)
it("renders no link when sourceUrl is not http(s)", () => {
const unsafeSourceFixture: AboutResponseDto = {
...ABOUT_FIXTURE,
attributions: {
groups: [
{
kind: "jingle-pack",
packs: [
{
slug: "gw-first-beds",
name: "GW First Beds",
attributions: [
{ title: "Night Drive", creator: "Jane Doe", sourceUrl: "javascript:alert(1)", license: "CC-BY" },
],
},
],
},
],
},
};

render(<AboutView about={unsafeSourceFixture} />);

expect(screen.queryByRole("link", { name: "Night Drive" })).not.toBeInTheDocument();
});
});

describe("Scenario: the sidebar footer", () => {
// Given: Sidebar rendered
it.todo("AC5 — About sits in the footer beside Sign out");
it("AC5 — About sits in the footer beside Sign out", async () => {
mockedUsePathname.mockReturnValue("/dashboard");
const { Sidebar } = await import("../app/(authed)/_components/Sidebar");

render(<Sidebar />);

const about = screen.getByRole("link", { name: "About" });
const signOut = screen.getByRole("button", { name: /sign out/i });
expect(about.closest("footer")).toContainElement(signOut);
});
});

});
24 changes: 19 additions & 5 deletions admin-ui/__specs__/app-shell.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ function collectFiles(dir: string, exts: string[], out: string[] = []): string[]
beforeEach(() => {
jest.clearAllMocks();
document.documentElement.removeAttribute("data-theme");
// jsdom's `window` (and its `localStorage`) is shared by every `it` in this FILE — a nav-group
// toggle written by one test (SPEC F203.2's remembered-toggle storage) must not leak into the
// next one's Sidebar/MobileNav mount.
window.localStorage.clear();
});

afterEach(() => {
Expand All @@ -148,13 +152,18 @@ afterEach(() => {

describe("Feature: App shell", () => {
describe("Scenario: shell wraps every authed route", () => {
it("renders the sidebar with Dashboard, Live, Catalog, Station Imaging, Settings (and NOT Libraries)", async () => {
it("renders the sidebar with Dashboard, Catalog, Station sounds, Settings (and NOT Libraries)", async () => {
mockedUsePathname.mockReturnValue("/dashboard");
const { Sidebar } = await import("../app/(authed)/_components/Sidebar");

render(<Sidebar />);
// Catalog (Media) and Station sounds (Station) live inside collapsible groups (SPEC F203.2)
// that don't open by route rule at /dashboard — open them by hand so this stays a shell-
// composition check, not a group-collapse check.
fireEvent.click(screen.getByRole("button", { name: "Station" }));
fireEvent.click(screen.getByRole("button", { name: "Media" }));

for (const label of ["Dashboard", "Live", "Catalog", "Station Imaging", "Settings"]) {
for (const label of ["Dashboard", "Catalog", "Station sounds", "Settings"]) {
expect(screen.getByRole("link", { name: label })).toBeInTheDocument();
}
// Libraries stays routable but unlisted until Q7.
Expand Down Expand Up @@ -185,7 +194,10 @@ describe("Feature: App shell", () => {
// ---------------------------------------------------------------------------
describe("Scenario: Persona Catalog nav entry is feature-gated (PLAN T102, SPEC F90.1)", () => {
it("hides the Persona Catalog link by default (no live signal, fail-closed)", async () => {
mockedUsePathname.mockReturnValue("/dashboard");
// /catalog is Media's own route, so the group opens by rule (SPEC F203.2) with no manual
// toggle — otherwise Media stays collapsed and this assertion would pass whether or not the
// gate exists (the link would be absent either way).
mockedUsePathname.mockReturnValue("/catalog");
const { Sidebar } = await import("../app/(authed)/_components/Sidebar");

render(<Sidebar />);
Expand All @@ -194,7 +206,9 @@ describe("Feature: App shell", () => {
});

it("lists the Persona Catalog link once the authed layout resolves catalogEnabled=true", async () => {
mockedUsePathname.mockReturnValue("/dashboard");
// Community Catalog lives in the Media group (SPEC F203.1) — /catalog is Media's own route,
// so the group opens by rule (SPEC F203.2) without a manual toggle.
mockedUsePathname.mockReturnValue("/catalog");
const { Sidebar } = await import("../app/(authed)/_components/Sidebar");

render(<Sidebar catalogEnabled />);
Expand All @@ -206,7 +220,7 @@ describe("Feature: App shell", () => {
});

it("mirrors the same gating in the sub-1024px drawer (MobileNav)", async () => {
mockedUsePathname.mockReturnValue("/dashboard");
mockedUsePathname.mockReturnValue("/catalog");
const { MobileNav } = await import("../app/(authed)/_components/MobileNav");

render(<MobileNav catalogEnabled />);
Expand Down
2 changes: 1 addition & 1 deletion admin-ui/__specs__/bed-picker-help-flyover.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @jest-environment jsdom
// gh-#431 — Station Imaging "Bed (optional)" gets a ? help flyover, matching the settings page's
// gh-#431 — Station sounds "Bed (optional)" gets a ? help flyover, matching the settings page's
// SettingHelpFlyover pattern: the shared `HelpFlyover` (gh-#209 extraction), the exact idiom the
// booth log's Mode column header already uses (LlmCallsFeed.tsx).
//
Expand Down
4 changes: 3 additions & 1 deletion admin-ui/__specs__/booth-log-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,9 @@ describe("Feature: Booth log", () => {

describe("Feature: Booth log sidebar entry", () => {
it("lists Booth log in the persistent sidebar nav", async () => {
mockedUsePathname.mockReturnValue("/dashboard");
// Booth log lives in the Status group (SPEC F203.1) — /booth-log is Status's own route, so the
// group opens by rule (SPEC F203.2) without a manual toggle.
mockedUsePathname.mockReturnValue("/booth-log");

const { Sidebar } = await import("../app/(authed)/_components/Sidebar");
render(<Sidebar />);
Expand Down
2 changes: 1 addition & 1 deletion admin-ui/__specs__/dashboard-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe("Feature: Dashboard", () => {
render(<DashboardView timeZone="UTC" />);
await flush();

expect(screen.getByText(/Station Imaging rotation — drain state/)).toBeInTheDocument();
expect(screen.getByText(/Station sounds rotation — drain state/)).toBeInTheDocument();
});

it("shows a warming-up state for 503", async () => {
Expand Down
Loading
Loading