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
98 changes: 98 additions & 0 deletions admin-ui/__specs__/bed-picker-help-flyover.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// @jest-environment jsdom
// gh-#431 — Station Imaging "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).
//
// Runner: Jest (jsdom) + @testing-library/react. BedPicker has no standalone page of its own —
// it renders inside SafeContentClient's Generate form (safe-content-redesign.spec.tsx's own
// coverage target), so this spec mounts that same client. VoiceControl fetches GET /api/voices
// on mount (STORY-098/F29.5); the fetch mock here is a generic ok/empty-array stub so that
// unrelated fetch never fails, mirroring safe-content-redesign.spec.tsx's own VOICES_MOUNT_SPEC
// precedent.

import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import { Toaster } from "@/components/ui/toast";
import { SafeContentClient } from "../app/(authed)/safe-content/SafeContentClient";
import type { SafeContentClientProps } from "../app/(authed)/safe-content/SafeContentClient";
import type { LibraryDto } from "../lib/library";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeLibraries(): LibraryDto[] {
return [{ id: 7, name: "safe", mediaCount: 2 }];
}

function renderClient(overrides: Partial<SafeContentClientProps> = {}): ReturnType<typeof render> {
const props: SafeContentClientProps = {
libraries: makeLibraries(),
initialLibraryId: 7,
initialSegments: [],
initialOutOfScope: false,
defaultText: "You're listening to {StationName}. We'll be right back — stay tuned.",
defaultTitle: "Please Stand By",
...overrides,
};
return render(
<>
<SafeContentClient {...props} />
<Toaster />
</>
);
}

// ---------------------------------------------------------------------------
// Feature: the Bed field explains itself (gh-#431)
// ---------------------------------------------------------------------------

describe("Feature: the Bed field explains itself", () => {
let originalFetch: typeof fetch;

beforeEach(() => {
originalFetch = global.fetch;
global.fetch = jest.fn<typeof fetch>().mockResolvedValue({
ok: true,
status: 200,
json: jest.fn<() => Promise<unknown>>().mockResolvedValue([]),
headers: new Headers({ "content-type": "application/json" }),
} as unknown as Response) as unknown as typeof fetch;
});

afterEach(() => {
global.fetch = originalFetch;
jest.clearAllMocks();
});

describe("Scenario: a ? flyover sits next to the Bed label", () => {
it("renders the ducking/padding/bake-in/deployment-setting help copy, mounted but hidden until asked for", () => {
renderClient();

const trigger = screen.getByRole("button", { name: "Help: Bed" });
expect(trigger).toHaveAttribute("aria-expanded", "false");

const panel = screen.getByTestId("bed-help");
expect(panel).toBeInTheDocument();
expect(panel).not.toBeVisible();

const copy = panel.textContent ?? "";
expect(copy).toMatch(/mixed UNDER the generated voice/);
expect(copy).toMatch(/main-catalog jingle or instrumental/);
expect(copy).toMatch(/ducked \(−12 dB\)/);
expect(copy).toMatch(/padded 1\.5 s before and after the voice/);
expect(copy).toMatch(/loops if shorter/);
expect(copy).toMatch(/honours the track's cue points/);
expect(copy).toMatch(/baked into the audio file at generate time/);
expect(copy).toMatch(/regenerating the segment/);
expect(copy).toMatch(/Duck\/pad amounts are deployment settings \(env\), not live settings/);
});

it("does not change the Bed field's accessible name (still labelled 'Bed (optional)')", () => {
renderClient();

expect(screen.getByRole("combobox", { name: /bed \(optional\)/i })).toBeInTheDocument();
});
});
});
6 changes: 4 additions & 2 deletions admin-ui/__specs__/settings-area-tabs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ describe("Feature: settings areas render as tabs", () => {
describe("Scenario (sad path): a rejected key on a non-visible tab is surfaced", () => {
it("a 400 auto-switches to the first offending tab and shows the inline error", async () => {
const validationProblem = {
errors: { settings: ["Must be a non-empty absolute http/https URL"] },
// gh-#425 real shape — keyed by the offending setting key, not a flat "settings" bucket.
errors: { "Tts:Endpoint": ["Must be a non-empty absolute http/https URL"] },
title: "One or more settings values are invalid.",
status: 400,
};
Expand All @@ -307,7 +308,8 @@ describe("Feature: settings areas render as tabs", () => {

it("marks the offending tab with a validation-error flag", async () => {
const validationProblem = {
errors: { settings: ["Must be a non-empty absolute http/https URL"] },
// gh-#425 real shape — keyed by the offending setting key, not a flat "settings" bucket.
errors: { "Tts:Endpoint": ["Must be a non-empty absolute http/https URL"] },
status: 400,
};
makeFetchMock(400, validationProblem);
Expand Down
10 changes: 10 additions & 0 deletions admin-ui/__specs__/settings-help-coverage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ describe("Feature: Every settings field explains itself", () => {
expect(
screen.getByTestId("setting-help-Library:CueDetection:MinSilenceDurationSec")
).toHaveTextContent(/greater than 0.*60/i);
// gh-#427 — pin the ±90/±180 lat/lon bounds stated in the help copy so they can't silently
// drift from the real coordinate ranges (SettingValidator itself is deliberately
// AlwaysValid for these two keys; WeatherContextProvider's own fail-closed check is what
// actually treats anything outside these ranges the same as blank — F108.1).
expect(screen.getByTestId("setting-help-Station:Location:Latitude")).toHaveTextContent(
/-90.*90/
);
expect(screen.getByTestId("setting-help-Station:Location:Longitude")).toHaveTextContent(
/-180.*180/
);
});
});

Expand Down
32 changes: 30 additions & 2 deletions admin-ui/__specs__/settings-page.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,10 @@ describe("Feature: Edit station settings", () => {

describe("Scenario: a rejected setting is surfaced", () => {
it("a 400 ValidationProblemDetails shows the real backend message and does not claim success", async () => {
// Real shape: ASP.NET Core ValidationProblemDetails with errors keyed under "settings"
// Real shape (gh-#425): ASP.NET Core ValidationProblemDetails, keyed by the actual
// offending setting key — not a flat "settings" bucket.
const validationProblem = {
errors: { settings: ["Must be between -40 and 0"] },
errors: { "Loudness:TargetLufs": ["Must be between -40 and 0"] },
title: "One or more settings values are invalid.",
status: 400,
};
Expand All @@ -394,5 +395,32 @@ describe("Feature: Edit station settings", () => {
// "Settings saved." must NOT appear
expect(screen.queryByRole("status")).toBeNull();
});

it("a per-key error paints only the offending field, not every changed field (gh-#425)", async () => {
// Two fields changed; the backend rejects only one of them, keyed by ITS key alone.
const validationProblem = {
errors: { "Loudness:TargetLufs": ["Must be between -40 and 0"] },
title: "One or more settings values are invalid.",
status: 400,
};
makeFetchMock(400, validationProblem);
const settings = makeSettings();
renderWithProviders(<SettingsForm settings={settings} />);

fireEvent.change(screen.getByLabelText(/Loudness:TargetLufs/), { target: { value: "50" } });
fireEvent.change(screen.getByLabelText(/GW_XFADE_MAX/), { target: { value: "10" } });

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /save settings/i }));
await Promise.resolve();
});

await waitFor(() => {
// Exactly one alert region exists, and it names the offending key's own message — the
// valid GW_XFADE_MAX change gets no error at all.
expect(screen.getAllByRole("alert")).toHaveLength(1);
expect(screen.getByRole("alert")).toHaveTextContent("Must be between -40 and 0");
});
});
});
});
213 changes: 213 additions & 0 deletions admin-ui/__specs__/settings-persona-control.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// @jest-environment jsdom
// gh-#426 — `Context:Weather:PersonaId`/`Context:History:PersonaId` get a persona dropdown
// instead of a bare number input. Both keys hold a persona ROW ID as a string on the wire;
// null/0 means "the on-air DJ (default)" (SPEC F107.7, SettingValidator's ContextPersonaIdMin
// remarks).
//
// Runner: Jest (jsdom) + @testing-library/react, mirroring settings-audience-control.spec.tsx's
// house pattern (renderWithProviders, makeSequencedFetchMock) — SettingsForm calls useConfirm()
// unconditionally, so every render needs a ConfirmDialogProvider ancestor.

import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import type { ReactElement } from "react";
import { ConfirmDialogProvider } from "@/components/ui/confirm-dialog";
import { Toaster } from "@/components/ui/toast";
import { SettingsForm } from "../app/(authed)/settings/SettingsForm";
import type { SettingDto } from "../app/(authed)/settings/SettingsForm";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const WEATHER_PERSONA_KEY = "Context:Weather:PersonaId";
const HISTORY_PERSONA_KEY = "Context:History:PersonaId";

/** Minimal roster fixture — `usePersonaList` only reads `id`/`name` off each row. */
const PERSONAS = [
{ id: 3, name: "Flip" },
{ id: 7, name: "Mike Rophone" },
];

function makePersonaIdSetting(
key: string,
overrides: Partial<SettingDto> = {}
): SettingDto {
return {
key,
value: "0",
source: "default",
applyMode: "live",
kind: "number",
unit: "",
...overrides,
};
}

interface MockResponseSpec {
status: number;
body?: unknown;
}

/** A fetch mock that replays one response per call, in order (last spec repeats if exhausted). */
function makeSequencedFetchMock(specs: MockResponseSpec[]): jest.MockedFunction<typeof fetch> {
let callIndex = 0;
const fn = jest.fn<typeof fetch>().mockImplementation(async () => {
const spec = specs[callIndex] ?? specs[specs.length - 1]!;
callIndex += 1;
return {
ok: spec.status >= 200 && spec.status < 300,
status: spec.status,
json: jest.fn<() => Promise<unknown>>().mockResolvedValue(spec.body ?? {}),
headers: new Headers(),
} as unknown as Response;
});
global.fetch = fn as unknown as typeof fetch;
return fn;
}

function renderWithProviders(node: ReactElement): ReturnType<typeof render> {
return render(
<ConfirmDialogProvider>
{node}
<Toaster />
</ConfirmDialogProvider>
);
}

// ---------------------------------------------------------------------------
// Feature: Context:*:PersonaId's dedicated Settings control
// ---------------------------------------------------------------------------

describe("Feature: Context:*:PersonaId's dedicated Settings control", () => {
let originalFetch: typeof fetch;

beforeEach(() => {
originalFetch = global.fetch;
});

afterEach(() => {
global.fetch = originalFetch;
jest.clearAllMocks();
});

describe("Scenario: the field renders as a persona dropdown", () => {
it("renders a select fed by GET /api/personas, default option first, current value preselected (gh-#426)", async () => {
makeSequencedFetchMock([{ status: 200, body: PERSONAS }]);
renderWithProviders(
<SettingsForm settings={[makePersonaIdSetting(WEATHER_PERSONA_KEY, { value: "7" })]} />
);

// "7" is already selectable before the roster loads (an unrecognized value always renders
// its own option — see the "unknown current persona id" scenario below), so wait for the
// roster itself to land rather than for `select.value`, which is stable across both states.
await waitFor(() => {
const select = screen.getByLabelText(new RegExp(WEATHER_PERSONA_KEY)) as HTMLSelectElement;
const optionLabels = Array.from(select.options).map((o) => o.textContent);
expect(optionLabels).toContain("Flip");
});

const select = screen.getByLabelText(new RegExp(WEATHER_PERSONA_KEY)) as HTMLSelectElement;
expect(select.tagName).toBe("SELECT");
expect(select.value).toBe("7");
const optionLabels = Array.from(select.options).map((o) => o.textContent);
expect(optionLabels[0]).toBe("On-air DJ (default)");
expect(optionLabels).toContain("Mike Rophone");
});

it("preselects the default option for value '0' (unset means the on-air DJ, F107.7)", async () => {
makeSequencedFetchMock([{ status: 200, body: PERSONAS }]);
renderWithProviders(
<SettingsForm settings={[makePersonaIdSetting(WEATHER_PERSONA_KEY, { value: "0" })]} />
);

await waitFor(() => {
const select = screen.getByLabelText(new RegExp(WEATHER_PERSONA_KEY)) as HTMLSelectElement;
expect(select.value).toBe("0");
});
});

it("an unrecognized current persona id gets its own 'Unknown persona (#id)' option, not a silent drop", async () => {
makeSequencedFetchMock([{ status: 200, body: PERSONAS }]);
renderWithProviders(
<SettingsForm settings={[makePersonaIdSetting(WEATHER_PERSONA_KEY, { value: "42" })]} />
);

await waitFor(() => {
const select = screen.getByLabelText(new RegExp(WEATHER_PERSONA_KEY)) as HTMLSelectElement;
expect(select.value).toBe("42");
});

expect(screen.getByText("Unknown persona (#42)")).toBeInTheDocument();
});
});

describe("Scenario: submission plumbing is untouched", () => {
it("picking a persona by name submits the id string on the shipped changed-keys PUT batch (F54.4)", async () => {
const mockFetch = makeSequencedFetchMock([
{ status: 200, body: PERSONAS },
{ status: 200 },
]);
renderWithProviders(
<SettingsForm settings={[makePersonaIdSetting(WEATHER_PERSONA_KEY, { value: "0" })]} />
);

const select = await waitFor(
() => screen.getByLabelText(new RegExp(WEATHER_PERSONA_KEY)) as HTMLSelectElement
);
fireEvent.change(select, { target: { value: "3" } });

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /save settings/i }));
await Promise.resolve();
});

await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit];
expect(url).toBe("/api/settings");
expect(init.method).toBe("PUT");
const body = JSON.parse(init.body as string) as Array<{ key: string; value: string }>;
expect(body).toEqual([{ key: WEATHER_PERSONA_KEY, value: "3" }]);
});

it("picking 'On-air DJ (default)' submits '0' explicitly", async () => {
const mockFetch = makeSequencedFetchMock([
{ status: 200, body: PERSONAS },
{ status: 200 },
]);
renderWithProviders(
<SettingsForm settings={[makePersonaIdSetting(WEATHER_PERSONA_KEY, { value: "3" })]} />
);

const select = await waitFor(
() => screen.getByLabelText(new RegExp(WEATHER_PERSONA_KEY)) as HTMLSelectElement
);
fireEvent.change(select, { target: { value: "0" } });

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /save settings/i }));
await Promise.resolve();
});

await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
const body = JSON.parse(init.body as string) as Array<{ key: string; value: string }>;
expect(body).toEqual([{ key: WEATHER_PERSONA_KEY, value: "0" }]);
});
});

describe("Scenario: both Context providers register the same control", () => {
it("Context:History:PersonaId also renders as a persona dropdown (gh-#426)", async () => {
makeSequencedFetchMock([{ status: 200, body: PERSONAS }]);
renderWithProviders(
<SettingsForm settings={[makePersonaIdSetting(HISTORY_PERSONA_KEY, { value: "0" })]} />
);

await waitFor(() => {
const select = screen.getByLabelText(new RegExp(HISTORY_PERSONA_KEY)) as HTMLSelectElement;
expect(select.tagName).toBe("SELECT");
});
});
});
});
Loading
Loading