moadim 3.7.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FleetRunSummary } from "../api/hooks";
import { ReliabilityPage } from "../pages/reliability/ReliabilityPage";
import { RunsPage } from "../pages/runs/RunsPage";
import { loadSort, nextSort, saveSort, sortRows, type SortAccessors } from "./tableSort";

interface Row {
  name: string;
  n: number | null;
}
const ACC: SortAccessors<Row, "name" | "n"> = { name: (r) => r.name, n: (r) => r.n };
const ROWS: Row[] = [
  { name: "b", n: 2 },
  { name: "a10", n: null },
  { name: "A2", n: 1 },
  { name: "c", n: 2 },
];
const names = (rows: Row[]) => rows.map((r) => r.name);

beforeEach(() => localStorage.clear());
afterEach(() => vi.restoreAllMocks());

describe("nextSort", () => {
  it("starts a new column at its default dir, flips, then clears", () => {
    const first = nextSort<"a" | "b">(null, "a", "desc");
    expect(first).toEqual({ key: "a", dir: "desc" });
    const second = nextSort(first, "a", "desc");
    expect(second).toEqual({ key: "a", dir: "asc" });
    expect(nextSort(second, "a", "desc")).toBeNull();
    expect(nextSort(second, "b", "asc")).toEqual({ key: "b", dir: "asc" });
    expect(nextSort({ key: "a", dir: "asc" }, "a", "asc")).toEqual({ key: "a", dir: "desc" });
  });
});

describe("sortRows", () => {
  it("returns a copy in natural order when unsorted", () => {
    const out = sortRows(ROWS, null, ACC);
    expect(out).toEqual(ROWS);
    expect(out).not.toBe(ROWS);
  });

  it("sorts numbers stably with nulls last in both directions", () => {
    expect(names(sortRows(ROWS, { key: "n", dir: "asc" }, ACC))).toEqual(["A2", "b", "c", "a10"]);
    expect(names(sortRows(ROWS, { key: "n", dir: "desc" }, ACC))).toEqual(["b", "c", "A2", "a10"]);
  });

  it("keeps input order among multiple nulls", () => {
    const rows = [
      { name: "x", n: null },
      { name: "y", n: 1 },
      { name: "z", n: null },
    ];
    expect(names(sortRows(rows, { key: "n", dir: "asc" }, ACC))).toEqual(["y", "x", "z"]);
  });

  it("sorts strings case-insensitively with numeric collation", () => {
    expect(names(sortRows(ROWS, { key: "name", dir: "asc" }, ACC))).toEqual(["A2", "a10", "b", "c"]);
    expect(names(sortRows(ROWS, { key: "name", dir: "desc" }, ACC))).toEqual(["c", "b", "a10", "A2"]);
  });
});

describe("loadSort / saveSort", () => {
  const KEYS = ["a", "b"] as const;

  it("round-trips and clears", () => {
    expect(loadSort("k", KEYS)).toBeNull();
    saveSort("k", { key: "b", dir: "asc" });
    expect(loadSort("k", KEYS)).toEqual({ key: "b", dir: "asc" });
    saveSort("k", null);
    expect(localStorage.getItem("k")).toBeNull();
  });

  it("rejects malformed or unknown values", () => {
    for (const raw of ["{", "null", "3", '{"key":"zz","dir":"asc"}', '{"key":"a","dir":"up"}']) {
      localStorage.setItem("k", raw);
      expect(loadSort("k", KEYS)).toBeNull();
    }
  });

  it("ignores storage errors", () => {
    vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
      throw new Error("quota");
    });
    expect(() => saveSort("k", { key: "a", dir: "asc" })).not.toThrow();
  });
});

function run(o: Partial<FleetRunSummary>): FleetRunSummary {
  const now = Math.floor(Date.now() / 1000);
  return {
    routine_id: "r1",
    routine_title: "Alpha",
    workbench: "wb",
    started_at: now - 100,
    started_at_local: "",
    finished_at: now - 90,
    finished_at_local: "",
    status: "success",
    exit_code: 0,
    ...o,
  };
}

function renderWith(page: React.ReactNode, key: unknown[], runs: FleetRunSummary[]) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  qc.setQueryData(key, runs);
  render(
    <QueryClientProvider client={qc}>
      <MemoryRouter>{page}</MemoryRouter>
    </QueryClientProvider>,
  );
}

const linkOrder = (table: HTMLElement) =>
  within(table)
    .getAllByRole("link")
    .map((a) => a.textContent)
    .filter((t) => t !== "VIEW RUN");

describe("sortable fleet tables", () => {
  it("sorts the Runs table by header click, exposes aria-sort, and persists", () => {
    const now = Math.floor(Date.now() / 1000);
    const runs = [
      run({ workbench: "w1", routine_title: "Bravo", started_at: now - 50, finished_at: now - 40 }),
      run({ workbench: "w2", routine_title: "Alpha", started_at: now - 60, finished_at: now - 10 }),
      run({ workbench: "w3", routine_title: "Charlie", started_at: now - 70, finished_at: null, status: "running" }),
    ];
    renderWith(<RunsPage />, ["routines", "runs", 100], runs);
    const table = screen.getByRole("table");
    expect(linkOrder(table)).toEqual(["Bravo", "Alpha", "Charlie"]);

    const durationBtn = screen.getByRole("button", { name: "DURATION" });
    fireEvent.click(durationBtn);
    expect(linkOrder(table)).toEqual(["Alpha", "Bravo", "Charlie"]);
    expect(durationBtn.closest("th")).toHaveAttribute("aria-sort", "descending");
    expect(JSON.parse(localStorage.getItem("moadim.client.runs.sort") ?? "")).toEqual({
      key: "duration",
      dir: "desc",
    });

    fireEvent.click(screen.getByRole("button", { name: "DURATION ▼" }));
    expect(screen.getByRole("button", { name: "DURATION ▲" }).closest("th")).toHaveAttribute("aria-sort", "ascending");
    expect(linkOrder(table)).toEqual(["Bravo", "Alpha", "Charlie"]);

    fireEvent.click(screen.getByRole("button", { name: "ROUTINE" }));
    expect(linkOrder(table)).toEqual(["Alpha", "Bravo", "Charlie"]);
    for (const name of ["STARTED", "STATUS", "EXIT CODE"]) fireEvent.click(screen.getByRole("button", { name }));
    expect(screen.getByRole("button", { name: "ROUTINE" }).closest("th")).toHaveAttribute("aria-sort", "none");
  });

  it("restores a persisted Reliability sort and cycles every column", () => {
    localStorage.setItem("moadim.client.reliability.sort", JSON.stringify({ key: "routine", dir: "asc" }));
    const now = Math.floor(Date.now() / 1000);
    const runs = [
      run({ routine_id: "z", routine_title: "Zulu", workbench: "z1", status: "failed", exit_code: 1 }),
      run({ routine_id: "a", routine_title: "Able", workbench: "a1", started_at: now - 500, finished_at: now - 100 }),
      // Finished but untimed: its p95 is null, so it sinks below timed routines.
      run({ routine_id: "m", routine_title: "Mike", workbench: "m1", started_at: now - 30, finished_at: null }),
    ];
    renderWith(<ReliabilityPage />, ["routines", "runs", 300], runs);
    const table = screen.getAllByRole("table").at(-1) as HTMLElement;
    expect(linkOrder(table)).toEqual(["Able", "Mike", "Zulu"]);
    expect(screen.getByRole("button", { name: "ROUTINE ▲" })).toBeInTheDocument();

    fireEvent.click(screen.getByRole("button", { name: "STREAK" }));
    expect(linkOrder(table)).toEqual(["Zulu", "Able", "Mike"]);
    fireEvent.click(screen.getByRole("button", { name: "P95" }));
    expect(linkOrder(table)).toEqual(["Able", "Zulu", "Mike"]);
    for (const name of ["SUCCESS RATE", "P50", "TREND"]) fireEvent.click(screen.getByRole("button", { name }));
    expect(screen.getByRole("button", { name: "TREND ▼" })).toBeInTheDocument();
  });
});