Files
omp-pygienium/tests/footer.test.ts

270 lines
9.0 KiB
TypeScript

/**
* footer.test.ts — unit tests for the pipeline-overview footer.
*
* The footer is a presentation-only multi-line `belowEditor` widget: with no
* UI it tracks items but writes nothing; with a stub UI it pushes a string[]
* of themed lines via `ui.setWidget` (key, lines, { placement: "belowEditor" })
* and clears on `done()`. The pure {@link renderFooterList} core is asserted
* directly (layout + theming); a light widget-glue test covers the wiring.
*/
import { describe, expect, it } from "bun:test";
import {
createPipelineFooter,
footerPhaseItems,
footerColor,
renderFooterList,
FOOTER_MARKER,
FOOTER_STATUS_KEY,
type FooterItem,
type FooterTheme,
} from "../src/footer.js";
import { PHASE_LABELS } from "../src/phases.js";
import { PHASE_ANALYSIS, PHASE_FIX, PHASE_RECON } from "../src/run-state.js";
/** A fake theme that wraps text as `<color>:<text>` so assertions can read it. */
function fakeTheme(): FooterTheme {
return { fg: (color, text) => `${color}:${text}` };
}
/** Minimal UI stub capturing `setWidget` calls (key, lines, options). */
function stubUi(theme: FooterTheme = fakeTheme()): {
ui: {
theme: FooterTheme;
setWidget: (
key: string,
content: string[] | undefined,
options?: { placement?: string },
) => void;
};
calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[];
} {
const calls: {
key: string;
content: string[] | undefined;
placement?: string;
}[] = [];
return {
calls,
ui: {
theme,
setWidget(key, content, options) {
calls.push({ key, content, placement: options?.placement });
},
},
};
}
/** Build the canonical phase-id list a scan-only check uses. */
function scanPhaseIds(): string[] {
return [PHASE_RECON, PHASE_ANALYSIS, PHASE_FIX];
}
/** Items for the canonical scan-only pipeline. */
function scanItems(status: FooterItem["status"] = "pending"): FooterItem[] {
return scanPhaseIds().map((id) => ({
label: PHASE_LABELS[id] ?? id,
status,
}));
}
describe("renderFooterList", () => {
it("renders one bulleted, numbered, themed line per phase", () => {
const lines = renderFooterList(scanItems(), -1, fakeTheme());
expect(lines).toHaveLength(3);
// Each line: `• <marker> <n>. <label>` wrapped `<color>:…`.
expect(lines[0]).toBe("dim:• · 1. Recon");
expect(lines[1]).toBe("dim:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes the cursor item as accent (running) and the rest as dim (pending)", () => {
const lines = renderFooterList(scanItems(), 1, fakeTheme());
expect(lines[0]).toBe("dim:• · 1. Recon");
// cursor (index 1) is pending-but-current → accent.
expect(lines[1]).toBe("accent:• · 2. Scanning");
expect(lines[2]).toBe("dim:• · 3. Fixing");
});
it("themes terminal statuses with success/error/warning colors", () => {
const items: FooterItem[] = [
{ label: "Recon", status: "complete" },
{ label: "Scan", status: "running" },
{ label: "Fix", status: "failed" },
{ label: "Verify", status: "skipped" },
];
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toBe("success:• ✓ 1. Recon");
expect(lines[1]).toBe("accent:• ● 2. Scan");
expect(lines[2]).toBe("error:• ✗ 3. Fix");
expect(lines[3]).toBe("warning:• ↷ 4. Verify");
});
it("pads the index to 2 digits when the pipeline has 10+ items", () => {
const items: FooterItem[] = Array.from({ length: 11 }, (_, i) => ({
label: `S${i}`,
status: "pending" as const,
}));
const lines = renderFooterList(items, -1, fakeTheme());
expect(lines[0]).toContain("01. S0");
expect(lines[10]).toContain("11. S10");
});
});
describe("footerColor", () => {
it("maps each status to its piolium-style color token", () => {
expect(footerColor("complete", false)).toBe("success");
expect(footerColor("failed", false)).toBe("error");
expect(footerColor("skipped", false)).toBe("warning");
expect(footerColor("running", false)).toBe("accent");
expect(footerColor("pending", false)).toBe("dim");
// A pending item under the cursor reads as accent (current).
expect(footerColor("pending", true)).toBe("accent");
});
});
describe("createPipelineFooter", () => {
it("is a no-op without a UI but still tracks item state", () => {
// hasUI false: setWidget must never be called.
const footer = createPipelineFooter({ hasUI: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// No UI → no observable side effect, but getItems reflects state.
expect(footer.getItems()[0]?.status).toBe("running");
expect(footer.getTitle()).toBe("pygienium smoke");
});
it("renders the full pipeline as a belowEditor widget and clears on done", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
footer.setPipeline("pygienium smoke", items, 0);
// One setWidget call, under the canonical key, placement belowEditor.
expect(calls).toHaveLength(1);
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
// Title line first (dim), then one bulleted line per phase.
expect(lines[0]).toBe("dim:pygienium smoke");
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. Recon`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. Scanning`);
expect(lines[3]).toBe(`dim:• ${FOOTER_MARKER.pending} 3. Fixing`);
// The cursor item is marked running.
expect(footer.getItems()[0]?.status).toBe("running");
footer.done();
// done() pushes an undefined to clear the slot, then resets state.
const last = calls[calls.length - 1]!;
expect(last.content).toBeUndefined();
expect(last.placement).toBe("belowEditor");
expect(footer.getItems()).toHaveLength(0);
});
it("demotes the previous running item to pending when the cursor moves", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
// recon → complete, then advance to analysis.
footer.setItem(0, "complete");
footer.setCursor(1);
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("running");
});
it("does not demote a terminal item when the cursor advances past it", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium smoke",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
0,
);
footer.setCursor(0); // recon running
footer.setItem(0, "complete");
footer.setCursor(1); // analysis running
footer.setItem(1, "complete");
footer.setCursor(2); // fix running
const items = footer.getItems();
expect(items[0]?.status).toBe("complete");
expect(items[1]?.status).toBe("complete");
expect(items[2]?.status).toBe("running");
});
it("marks a skipped gate as every item skipped", () => {
const { ui } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true });
footer.setPipeline(
"pygienium comments",
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
);
for (let i = 0; i < footer.getItems().length; i++) {
footer.setItem(i, "skipped");
}
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
});
it("can be disabled so it never touches the widget slot", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({ ui, hasUI: true, enabled: false });
footer.setPipeline("pygienium smoke", [
{ label: "Recon", status: "pending" },
]);
footer.setCursor(0);
footer.done();
// enabled:false suppresses every setWidget call (used by /pygienium-all
// which owns its own footer).
expect(calls).toHaveLength(0);
});
it("writes under a custom widget key (all-run owns its slot)", () => {
const { ui, calls } = stubUi();
const footer = createPipelineFooter({
ui,
hasUI: true,
statusKey: "pygienium-all",
});
footer.setPipeline(
"pygienium: all",
[
{ label: "comments", status: "pending" },
{ label: "dead-code", status: "pending" },
],
0,
);
expect(calls[0]?.key).toBe("pygienium-all");
expect(calls[0]?.placement).toBe("belowEditor");
const lines = calls[0]?.content ?? [];
expect(lines[0]).toBe("dim:pygienium: all");
// Cursor on the first check; second still pending (to come).
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
});
});
describe("footerPhaseItems", () => {
it("maps phase ids to pending footer items using PHASE_LABELS", () => {
const items = footerPhaseItems(scanPhaseIds(), PHASE_LABELS);
expect(items.map((i) => i.label)).toEqual(["Recon", "Scanning", "Fixing"]);
expect(items.every((i) => i.status === "pending")).toBe(true);
});
it("falls back to the raw id for unknown phases", () => {
const items = footerPhaseItems(["custom"], PHASE_LABELS);
expect(items[0]?.label).toBe("custom");
});
});