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
109 changes: 107 additions & 2 deletions admin-ui/__specs__/font-pack-shelf-specimen.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,19 @@ const FONT_DETAIL: CatalogEntryDetailDto = {
fontFamily: "Space Grotesk",
fontByteTotal: 7844,
fontSpecimenFile: "libre-grotesk-variable-latin.woff2",
fontLicense: "OFL-1.1",
fontVersion: "2.000",
fontSubset: "latin",
};

// PLAN T204 (Dean's post-v3.1.0 review): "no mention of license anywhere in the panel" — the
// all-null edge, mirroring a manifest that failed to parse server-side (CatalogController's own
// degrade-not-500 posture).
const FONT_DETAIL_WITHOUT_LICENCE: CatalogEntryDetailDto = {
...FONT_DETAIL,
fontLicense: null,
fontVersion: null,
fontSubset: null,
};

const ENTRY_URL = "/api/catalog/entries/libre-grotesk";
Expand Down Expand Up @@ -266,13 +279,20 @@ describe("Feature: packs on the shelf with an honest specimen", () => {
});

/** Opens Libre Grotesk's detail panel via the shelf card's own title text (the fontFamily "Space
* Grotesk" — see this file's own fixture remarks for why it deliberately differs from the slug). */
async function openLibreGroteskDetail(fetchMock: jest.MockedFunction<typeof fetch>): Promise<void> {
* Grotesk" — see this file's own fixture remarks for why it deliberately differs from the slug).
* `installedFontSlugs` (PLAN T204) defaults to `[]`, the same "not installed" default
* `PersonaCatalogClient`'s own prop carries — pass `["libre-grotesk"]` to exercise the
* already-installed path. */
async function openLibreGroteskDetail(
fetchMock: jest.MockedFunction<typeof fetch>,
installedFontSlugs: string[] = []
): Promise<void> {
global.fetch = fetchMock;
render(
<>
<PersonaCatalogClient
initialIndex={{ entries: [FONT_ENTRY], fetchedAt: "2026-08-05T00:00:00Z", unreachable: false }}
installedFontSlugs={installedFontSlugs}
/>
<Toaster />
</>
Expand Down Expand Up @@ -425,6 +445,67 @@ describe("Feature: packs on the shelf with an honest specimen", () => {
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(await screen.findByText('"Space Grotesk" installed.')).toBeInTheDocument();
});

it("flips the detail panel to Installed/Re-install locally once the install succeeds, no reload (PLAN T204)", async () => {
const fetchMock = fontFlowFetchMock();
// Starts NOT installed — the default `installedFontSlugs=[]` — so the button starts "Install".
await openInstallDialog(fetchMock);

const dialog = within(screen.getByRole("dialog"));
await act(async () => {
fireEvent.click(dialog.getByRole("button", { name: "Confirm install" }));
await Promise.resolve();
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());

// The detail panel itself (still open — only the confirm dialog closed) now reads installed,
// with no second fetch and no page reload: PersonaCatalogClient.handleFontInstalled flips its
// own local state on the toast, the cheap path this task's own spec calls for.
expect(screen.getByText("Installed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Re-install" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
});
});

describe("Scenario: the licence is visible before install (PLAN T204, Dean's post-v3.1.0 review)", () => {
it("shows the licence · version · subset line on the pre-install review panel", async () => {
const fetchMock = fontFlowFetchMock();
await openLibreGroteskDetail(fetchMock);

expect(await screen.findByText("OFL-1.1 · v2.000 · latin")).toBeInTheDocument();
});

it("degrades to 'Licence unknown' rather than a blank line when the manifest carries none", async () => {
const fetchMock = fontFlowFetchMock({ entry: makeJsonResponse(200, FONT_DETAIL_WITHOUT_LICENCE) });
await openLibreGroteskDetail(fetchMock);

expect(await screen.findByText("Licence unknown")).toBeInTheDocument();
});
});

describe("Scenario: installed-state awareness (PLAN T204, Dean's post-v3.1.0 review)", () => {
it("shows Install and a state-neutral specimen caption when the pack is not installed", async () => {
const fetchMock = fontFlowFetchMock();
await openLibreGroteskDetail(fetchMock);
await screen.findByTestId("font-specimen");

expect(screen.getByRole("button", { name: "Install" })).toBeInTheDocument();
expect(screen.queryByText("Installed")).not.toBeInTheDocument();
expect(screen.getByText("Transient specimen — previewing installs nothing")).toBeInTheDocument();
});

it("shows an Installed chip, Re-install, and the SAME neutral caption when the pack is already installed", async () => {
const fetchMock = fontFlowFetchMock();
await openLibreGroteskDetail(fetchMock, ["libre-grotesk"]);
await screen.findByTestId("font-specimen");

expect(screen.getByText("Installed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Re-install" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
// The specimen caption never claims install state either way (F104.4's own "transient,
// installs nothing" fact is true regardless) — see SpecimenBlock's own remarks.
expect(screen.getByText("Transient specimen — previewing installs nothing")).toBeInTheDocument();
});
});

// ── SAD PATH ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -480,4 +561,28 @@ describe("Feature: packs on the shelf with an honest specimen", () => {
expect(fetchMock.mock.calls.some(([url]) => String(url) === INSTALL_URL)).toBe(false);
});
});

describe("Scenario: a failed install flips nothing (gh-#375 review carry-forward, N3)", () => {
it("flips nothing locally — the detail panel behind the dialog still reads Install, not Installed", async () => {
const fetchMock = fontFlowFetchMock({
install: makeJsonResponse(409, { detail: "This pack is already installed under a different family." }),
});
await openInstallDialog(fetchMock);

const dialog = within(screen.getByRole("dialog"));
await act(async () => {
fireEvent.click(dialog.getByRole("button", { name: "Confirm install" }));
await Promise.resolve();
});
await screen.findByRole("alert");

// `onInstalled` (PersonaCatalogClient.handleFontInstalled) only ever fires on
// FontInstallModal's own resp.ok branch — a 409 never reaches it, so the detail panel's own
// Install button, still present behind the open dialog, never flips to Re-install.
// `getByText`, not `getByRole` (Radix marks the background `aria-hidden` while the dialog is
// open, which `*ByRole` correctly excludes but a plain text query does not).
expect(screen.getByText("Install")).toBeInTheDocument();
expect(screen.queryByText("Installed")).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// @jest-environment jsdom
// STORY-284 — The library is inspectable (SPEC F104.7 · PLAN T203); AC2 is the 🖐️ T204 gate.
// STORY-284 — The wardrobe is inspectable (SPEC F104.7 · PLAN T203); AC2 is the 🖐️ T204 gate.
// Nav label/route renamed "Library" → "Wardrobe" at PLAN T204 (Dean's ruling) — this file was
// font-pack-library.spec.tsx before that rename; no behavior changed, only names.
//
// Runner: Jest. FontLibraryClient renders GET /api/fonts's own listing — family (title), faces
// Runner: Jest. WardrobeClient renders GET /api/fonts's own listing — family (title), faces
// (style + byte size via the shared font-format.ts helper), the licence/version/subset line, and
// the "Installed · <slug> · <date>" db/25 provenance chip (AC1). `timeZone="UTC"` is pinned
// explicitly (the StatusTiles/BoothLogFeed/PersonasClient/SettingsForm house idiom, T105/T187) so
Expand All @@ -10,8 +12,8 @@
import { describe, it, expect } from "@jest/globals";
import { render, screen, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { FontLibraryClient } from "../app/(authed)/library/FontLibraryClient";
import type { FontLibraryPackDto } from "../app/(authed)/library/types";
import { WardrobeClient } from "../app/(authed)/wardrobe/WardrobeClient";
import type { FontLibraryPackDto } from "../app/(authed)/wardrobe/types";

const SPACE_GROTESK_PACK: FontLibraryPackDto = {
slug: "space-grotesk",
Expand All @@ -25,10 +27,10 @@ const SPACE_GROTESK_PACK: FontLibraryPackDto = {
importedAt: "2026-08-05T12:00:00Z",
};

describe("Feature: the library is inspectable", () => {
describe("Scenario: the library lists installed packs", () => {
describe("Feature: the wardrobe is inspectable", () => {
describe("Scenario: the wardrobe lists installed packs", () => {
it("shows family, faces, byte sizes, and licence per pack (T203, AC1)", () => {
render(<FontLibraryClient packs={[SPACE_GROTESK_PACK]} timeZone="UTC" />);
render(<WardrobeClient packs={[SPACE_GROTESK_PACK]} timeZone="UTC" />);

const list = screen.getByRole("list", { name: "Installed font packs" });
const card = within(list).getByText("Space Grotesk").closest("li");
Expand All @@ -40,18 +42,18 @@ describe("Feature: the library is inspectable", () => {
});

it("shows 'Installed · <slug> · <date>' provenance per pack (T203, AC1)", () => {
render(<FontLibraryClient packs={[SPACE_GROTESK_PACK]} timeZone="UTC" />);
render(<WardrobeClient packs={[SPACE_GROTESK_PACK]} timeZone="UTC" />);

expect(screen.getByText("Installed · space-grotesk · Aug 5, 2026")).toBeInTheDocument();
});
});

describe("Scenario: the library is empty", () => {
describe("Scenario: the wardrobe is empty", () => {
// T203 review finding F3: the empty-state CTA must not point at /persona-catalog when the
// catalog is disabled — that route itself 404s off-catalog, the exact dead end the Library nav
// catalog is disabled — that route itself 404s off-catalog, the exact dead end the Wardrobe nav
// item's own deliberate ungating (SPEC F104.8) exists to let an operator avoid.
it("names the reason and offers the Community Catalog CTA when the catalog is enabled", () => {
render(<FontLibraryClient packs={[]} timeZone="UTC" catalogEnabled />);
render(<WardrobeClient packs={[]} timeZone="UTC" catalogEnabled />);

expect(screen.getByText("No packs installed")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Browse the Community Catalog" })).toHaveAttribute(
Expand All @@ -61,7 +63,7 @@ describe("Feature: the library is inspectable", () => {
});

it("points at Settings instead of the catalog when the catalog is disabled", () => {
render(<FontLibraryClient packs={[]} timeZone="UTC" catalogEnabled={false} />);
render(<WardrobeClient packs={[]} timeZone="UTC" catalogEnabled={false} />);

expect(screen.getByText("No packs installed")).toBeInTheDocument();
expect(
Expand All @@ -82,7 +84,7 @@ describe("Feature: the library is inspectable", () => {
version: null,
subset: null,
};
render(<FontLibraryClient packs={[packWithoutManifest]} timeZone="UTC" />);
render(<WardrobeClient packs={[packWithoutManifest]} timeZone="UTC" />);

expect(screen.getByText("Licence unknown")).toBeInTheDocument();
});
Expand Down
107 changes: 107 additions & 0 deletions admin-ui/__specs__/persona-catalog-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,113 @@ describe("Feature: The Persona Catalog server page", () => {
});
});

describe("Scenario: installed-font-slug wiring (PLAN T204, Dean's post-v3.1.0 review)", () => {
const indexBody: CatalogIndexResponseDto = {
entries: [EVERYONE_ENTRY],
fetchedAt: "2026-07-26T00:00:00Z",
unreachable: false,
};

it("threads GET /api/fonts's own slugs into PersonaCatalogClient as installedFontSlugs", async () => {
global.fetch = jest.fn<typeof fetch>().mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
if (url.endsWith("/api/fonts")) return makeJsonResponse(200, [{ slug: "space-grotesk" }, { slug: "libre-grotesk" }]);
throw new Error(`unexpected fetch ${url}`);
}) as unknown as typeof fetch;

const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
const node = await PersonaCatalogPage();

const clientEl = findElementByType(node, PersonaCatalogClient);
expect(clientEl?.props["installedFontSlugs"]).toEqual(["space-grotesk", "libre-grotesk"]);
});

it("degrades to an empty list — never crashing the page — when GET /api/fonts fails", async () => {
global.fetch = jest.fn<typeof fetch>().mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
if (url.endsWith("/api/fonts")) return makeJsonResponse(500, {});
throw new Error(`unexpected fetch ${url}`);
}) as unknown as typeof fetch;

const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
const node = await PersonaCatalogPage();

const clientEl = findElementByType(node, PersonaCatalogClient);
expect(clientEl?.props["installedFontSlugs"]).toEqual([]);
});
});

describe("Scenario: installed-theme-provenance wiring (gh-#375 — the theme half of PLAN T204's font-half fix)", () => {
const indexBody: CatalogIndexResponseDto = {
entries: [EVERYONE_ENTRY],
fetchedAt: "2026-07-26T00:00:00Z",
unreachable: false,
};

/** A `GET /api/settings` body carrying `Station:Theme`'s own choices (SPEC F103.11, PLAN
* T187) — only the fields `fetchInstalledThemeProvenance` reads. */
const settingsBody = [
{
key: "Station:Theme",
value: "cats-whisker",
source: "default",
applyMode: "live",
kind: "choice",
unit: "",
choices: [
{ value: "cats-whisker", label: "Cat's Whisker", isDefault: true },
{
value: "midnight-drive",
label: "Midnight Drive",
importedFrom: "midnight-drive-catalog-entry",
importedAt: "2026-07-21T09:05:00Z",
},
{ value: "aurora-glow", label: "Aurora Glow", importedFrom: "file", importedAt: "2026-07-20T14:32:00Z" },
],
},
];

it("threads Station:Theme's own imported choices into PersonaCatalogClient as installedThemeProvenance", async () => {
global.fetch = jest.fn<typeof fetch>().mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
if (url.endsWith("/api/fonts")) return makeJsonResponse(200, []);
if (url.endsWith("/api/settings")) return makeJsonResponse(200, settingsBody);
throw new Error(`unexpected fetch ${url}`);
}) as unknown as typeof fetch;

const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
const node = await PersonaCatalogPage();

const clientEl = findElementByType(node, PersonaCatalogClient);
// Every choice carrying provenance rides through — a shipped default (no importedFrom) does
// not, mirroring the theme-provenance-badge.spec.tsx precedent's own "shipped default never
// gets a row" rule one layer up.
expect(clientEl?.props["installedThemeProvenance"]).toEqual([
{ slug: "midnight-drive", importedFrom: "midnight-drive-catalog-entry", importedAt: "2026-07-21T09:05:00Z" },
{ slug: "aurora-glow", importedFrom: "file", importedAt: "2026-07-20T14:32:00Z" },
]);
});

it("degrades to an empty list — never crashing the page — when GET /api/settings fails", async () => {
global.fetch = jest.fn<typeof fetch>().mockImplementation(async (input) => {
const url = String(input);
if (url.endsWith("/api/catalog/index")) return makeJsonResponse(200, indexBody);
if (url.endsWith("/api/fonts")) return makeJsonResponse(200, []);
if (url.endsWith("/api/settings")) return makeJsonResponse(500, {});
throw new Error(`unexpected fetch ${url}`);
}) as unknown as typeof fetch;

const { default: PersonaCatalogPage } = await import("../app/(authed)/persona-catalog/page");
const node = await PersonaCatalogPage();

const clientEl = findElementByType(node, PersonaCatalogClient);
expect(clientEl?.props["installedThemeProvenance"]).toEqual([]);
});
});

describe("Scenario: disabled surface is a bare 404 (SPEC F90.1, sad path)", () => {
it("renders an inline 'Not found' page when GET /api/catalog/index 404s", async () => {
global.fetch = jest
Expand Down
Loading
Loading