270 lines
9.0 KiB
TypeScript
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");
|
|
});
|
|
});
|