Files
Auto-Trader/frontend/src/pages/__tests__/Scheduled.test.tsx
T

309 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Scheduled } from "@/pages/Scheduled";
import { ApiError, api, type ScheduledRun, type VerdictRecord } from "@/lib/api";
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: {
...actual.api,
listScheduledRuns: vi.fn(),
createScheduledRun: vi.fn(),
deleteScheduledRun: vi.fn(),
},
};
});
const mocked = api as unknown as {
listScheduledRuns: ReturnType<typeof vi.fn>;
createScheduledRun: ReturnType<typeof vi.fn>;
deleteScheduledRun: ReturnType<typeof vi.fn>;
};
function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
return {
id: "auckland-scan",
prompt: "pre-open scan of NZX names",
schedule: "30 23 * * 1-5",
next_run_at: 1_790_000_000_000,
status: "pending",
created_at: 1_780_000_000_000,
last_run_at: null,
consecutive_failures: 0,
last_error: null,
failure_kind: null,
config: {},
timezone: "Pacific/Auckland",
delivery_channel: null,
delivery_target: null,
delivery_format: null,
delivery_status: "none",
delivery_error: null,
delivery_updated_at: null,
last_verdict: null,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
mocked.listScheduledRuns.mockResolvedValue([]);
});
describe("Scheduled page", () => {
it("renders the stored local cadence and timezone without UTC conversion", async () => {
mocked.listScheduledRuns.mockResolvedValue([run()]);
render(<Scheduled />);
expect(await screen.findByText("Mon–Fri at 23:30")).toBeInTheDocument();
const row = screen.getByRole("listitem");
expect(within(row).getByText("Pacific/Auckland")).toBeInTheDocument();
expect(within(row).getByText("pre-open scan of NZX names")).toBeInTheDocument();
});
it("shows legacy timezone-less jobs as UTC", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ timezone: null, schedule: "0 9 * * *" })]);
render(<Scheduled />);
expect(await screen.findByText("Daily at 09:00")).toBeInTheDocument();
expect(within(screen.getByRole("listitem")).getByText("UTC")).toBeInTheDocument();
});
it("creates a job from the wall-clock composer", async () => {
mocked.createScheduledRun.mockResolvedValue(run());
render(<Scheduled />);
await screen.findByText(/No scheduled runs yet/);
fireEvent.change(screen.getByLabelText("Research prompt"), {
target: { value: "scan the pre-open movers" },
});
fireEvent.change(screen.getByLabelText("Local time"), { target: { value: "23:30" } });
fireEvent.submit(screen.getByRole("button", { name: /Schedule run/ }));
await waitFor(() =>
expect(mocked.createScheduledRun).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "scan the pre-open movers",
schedule: "30 23 * * 1-5",
timezone: expect.any(String),
}),
),
);
});
it("surfaces a validation error from the backend", async () => {
mocked.createScheduledRun.mockRejectedValue(
new ApiError("timezone 'Not/AZone' is not a recognized IANA timezone key", 422),
);
render(<Scheduled />);
await screen.findByText(/No scheduled runs yet/);
fireEvent.change(screen.getByLabelText("Research prompt"), {
target: { value: "scan" },
});
fireEvent.submit(screen.getByRole("button", { name: /Schedule run/ }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"not a recognized IANA timezone",
);
});
it("deletes only after an explicit confirm click, and cancel disarms", async () => {
mocked.listScheduledRuns.mockResolvedValue([run()]);
mocked.deleteScheduledRun.mockResolvedValue(undefined);
render(<Scheduled />);
fireEvent.click(await screen.findByRole("button", { name: /Delete scheduled run/ }));
expect(mocked.deleteScheduledRun).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("button", { name: /Confirm deleting/ })).not.toBeInTheDocument();
expect(mocked.deleteScheduledRun).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Delete scheduled run/ }));
fireEvent.click(screen.getByRole("button", { name: /Confirm deleting/ }));
await waitFor(() =>
expect(mocked.deleteScheduledRun).toHaveBeenCalledWith("auckland-scan"),
);
});
it("blocks a whitespace-only prompt client-side", async () => {
render(<Scheduled />);
await screen.findByText(/No scheduled runs yet/);
fireEvent.change(screen.getByLabelText("Research prompt"), {
target: { value: " " },
});
fireEvent.submit(screen.getByRole("button", { name: /Schedule run/ }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Enter a research prompt",
);
expect(mocked.createScheduledRun).not.toHaveBeenCalled();
});
});
describe("briefing delivery", () => {
it("keeps delivery off unless a channel is typed", async () => {
render(<Scheduled />);
fireEvent.change(screen.getByLabelText(/prompt/i), {
target: { value: "pre-open scan" },
});
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
await waitFor(() => expect(mocked.createScheduledRun).toHaveBeenCalled());
const body = mocked.createScheduledRun.mock.calls[0][0];
expect(body.delivery_channel).toBeNull();
expect(body.delivery_target).toBeNull();
});
it("refuses a channel with no target instead of sending nowhere", async () => {
render(<Scheduled />);
fireEvent.change(screen.getByLabelText(/prompt/i), {
target: { value: "pre-open scan" },
});
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
target: { value: "telegram" },
});
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(mocked.createScheduledRun).not.toHaveBeenCalled();
});
it("sends the channel and target it was given", async () => {
render(<Scheduled />);
fireEvent.change(screen.getByLabelText(/prompt/i), {
target: { value: "pre-open scan" },
});
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
target: { value: " telegram " },
});
fireEvent.change(screen.getByLabelText(/channel target/i), {
target: { value: " chat-9 " },
});
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
await waitFor(() => expect(mocked.createScheduledRun).toHaveBeenCalled());
const body = mocked.createScheduledRun.mock.calls[0][0];
expect(body.delivery_channel).toBe("telegram");
expect(body.delivery_target).toBe("chat-9");
});
it("offers HTML and PDF presentation only for email delivery", async () => {
render(<Scheduled />);
fireEvent.change(screen.getByLabelText(/prompt/i), {
target: { value: "daily report" },
});
expect(screen.queryByLabelText(/email report format/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
target: { value: "email" },
});
fireEvent.change(screen.getByLabelText(/channel target/i), {
target: { value: "reader@example.test" },
});
fireEvent.change(screen.getByLabelText(/email report format/i), {
target: { value: "pdf" },
});
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
await waitFor(() => expect(mocked.createScheduledRun).toHaveBeenCalled());
const body = mocked.createScheduledRun.mock.calls[0][0];
expect(body.delivery_format).toBe("pdf");
});
it("shows a monitor's delivery state, and shows nothing when it has none", async () => {
mocked.listScheduledRuns.mockResolvedValue([
run({ id: "with", delivery_channel: "telegram", delivery_status: "sent" }),
run({ id: "without" }),
]);
render(<Scheduled />);
expect(await screen.findByText(/delivered to telegram/i)).toBeInTheDocument();
expect(screen.queryAllByText(/delivered to/i)).toHaveLength(1);
});
it("surfaces why a delivery failed rather than only that it did", async () => {
mocked.listScheduledRuns.mockResolvedValue([
run({
delivery_channel: "telegram",
delivery_status: "failed",
delivery_error: "channel unreachable",
}),
]);
render(<Scheduled />);
expect(await screen.findByText(/channel unreachable/i)).toBeInTheDocument();
});
});
function verdict(overrides: Partial<VerdictRecord> = {}): VerdictRecord {
return {
session_id: "sess-1",
recorded_at: 1_789_000_000_000,
parse: "ok",
outcome: "FLAT",
items: [{ symbol: "600519.SH", state: "FLAT", reason: "band held" }],
previous: null,
...overrides,
};
}
describe("Scheduled page verdict cell", () => {
beforeEach(() => {
vi.clearAllMocks();
mocked.listScheduledRuns.mockResolvedValue([]);
});
it("renders the latest verdict with its delta and the recorded time", async () => {
mocked.listScheduledRuns.mockResolvedValue([
run({
last_verdict: verdict({
outcome: "DRIFT",
items: [{ symbol: "600519.SH", state: "DRIFT", reason: "band crossed" }],
previous: verdict({ session_id: "sess-0", outcome: "FLAT", recorded_at: 1_788_000_000_000 }),
}),
}),
]);
render(<Scheduled />);
expect(await screen.findByText(/600519.SH DRIFT/)).toBeInTheDocument();
expect(screen.getByText(/FLAT → DRIFT/)).toBeInTheDocument();
expect(screen.getByText(/as of/)).toBeInTheDocument();
});
it("shows an explicit empty state when no run has recorded one", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ last_verdict: null })]);
render(<Scheduled />);
expect(await screen.findByText("No verdict yet")).toBeInTheDocument();
});
it("renders nothing for ad-hoc monitors permanently at no_verdict_section", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ last_verdict: verdict({ parse: "no_verdict_section", items: [] }) })]);
render(<Scheduled />);
await screen.findByRole("listitem");
expect(screen.queryByText(/No verdict yet/)).not.toBeInTheDocument();
expect(screen.queryByTestId("verdict-line-auckland-scan")).toBeNull();
});
it("never shows a wrong verdict: a malformed section reads as unreadable", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ last_verdict: verdict({ parse: "contract_violation", items: [] }) })]);
render(<Scheduled />);
expect(await screen.findByText("Latest verdict unreadable")).toBeInTheDocument();
});
it("reads an empty item list as a real 'no calls' answer", async () => {
mocked.listScheduledRuns.mockResolvedValue([run({ last_verdict: verdict({ items: [] }) })]);
render(<Scheduled />);
expect(await screen.findByText(/No calls/)).toBeInTheDocument();
});
});