Files
multica/packages/views/autopilots/components/schedule-editor/validate.test.ts
YYClaw 465546b83b feat(autopilots): redesign the autopilot schedule editor (#5457)
Replace the free-form trigger-config form with a structured schedule
editor built on an orthogonal cron model: separate frequency, time, and
day-of-week/day-of-month dimensions map to and from cron expressions via
a dedicated grammar and mapping layer, with validation and a
human-readable describe() summary. The grammar suite drives the editor
against a combinatorially generated corpus of 51,755 distinct cron
expressions - every token form of every field, crossed - each judged
against a reference robfig/cron v3 parser.

Add a server-side /autopilot/cron-preview endpoint (plus schema and
React Query hook) so the editor shows upcoming run times, and echo
wildcard-carrying cron lists correctly instead of collapsing them.

Supporting pieces: timezone-aware formatting helper, segmented-toggle
and debounced-value utilities, a reworked time-input, and refreshed
en/ja/ko/zh-Hans locale strings.
2026-07-17 16:44:31 +08:00

81 lines
2.9 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "@multica/core/api";
import { findScheduleRejection } from "./validate";
import { parseCron } from "./cron-mapping";
// The submit paths call this right before writing: the editor's inline error
// only covers previews that have settled, so a cron typed and saved in one
// motion would otherwise slip through. Only a 400 may block the save — an
// unreachable preview endpoint must not keep anyone from saving a schedule the
// server would have accepted.
const behavior: { mode: "ok" | "invalid_cron" | "invalid_timezone" | "transport" | "no_code" } = {
mode: "ok",
};
vi.mock("@multica/core/autopilots/queries", () => ({
cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({
queryKey: ["autopilots", wsId, "cron-preview", expr, tz, behavior.mode],
queryFn: async () => {
if (behavior.mode === "transport") {
throw new ApiError("API error: 502 Bad Gateway", 502, "Bad Gateway");
}
if (behavior.mode === "invalid_cron") {
throw new ApiError("parse cron: expected exactly 5 fields", 400, "Bad Request", {
error: "parse cron: expected exactly 5 fields",
code: "invalid_cron",
});
}
if (behavior.mode === "invalid_timezone") {
throw new ApiError(`invalid timezone "${tz}"`, 400, "Bad Request", {
error: `invalid timezone "${tz}"`,
code: "invalid_timezone",
});
}
if (behavior.mode === "no_code") {
throw new ApiError("bad request", 400, "Bad Request", "bad request");
}
return { next_runs: ["2126-07-14T01:00:00Z"] };
},
retry: false,
}),
}));
function run() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return findScheduleRejection(qc, "ws-test", parseCron("0 9 * * *", "UTC"));
}
describe("findScheduleRejection", () => {
it("returns null when the server accepts the schedule", async () => {
behavior.mode = "ok";
await expect(run()).resolves.toBeNull();
});
it("returns the cron rejection with the parser's own words", async () => {
behavior.mode = "invalid_cron";
await expect(run()).resolves.toEqual({
code: "invalid_cron",
detail: "parse cron: expected exactly 5 fields",
});
});
it("blames the timezone when the server rejects the zone", async () => {
behavior.mode = "invalid_timezone";
const rejection = await run();
expect(rejection?.code).toBe("invalid_timezone");
});
it("falls back to invalid_cron when a 400 carries no readable code", async () => {
behavior.mode = "no_code";
const rejection = await run();
expect(rejection?.code).toBe("invalid_cron");
});
it("does not block the save on a transport or server failure", async () => {
behavior.mode = "transport";
await expect(run()).resolves.toBeNull();
});
});