feat: chat rendering toolcalls, footer overview
This commit is contained in:
@@ -1,33 +1,58 @@
|
||||
/**
|
||||
* footer.test.ts — unit tests for the pipeline-overview footer (task: footer).
|
||||
* footer.test.ts — unit tests for the pipeline-overview footer.
|
||||
*
|
||||
* The footer is presentation-only state: with no UI it tracks items but writes
|
||||
* nothing; with a stub UI it renders one status line and clears on `done()`.
|
||||
* These tests exercise the state machine (cursor demotion, terminal marks)
|
||||
* without spinning up a runner.
|
||||
* 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,
|
||||
FOOTER_GLYPH,
|
||||
footerColor,
|
||||
renderFooterList,
|
||||
FOOTER_MARKER,
|
||||
FOOTER_STATUS_KEY,
|
||||
type ItemStatus,
|
||||
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";
|
||||
|
||||
/** Minimal UI stub capturing `setStatus(key, text)` calls in order. */
|
||||
function stubUi(): {
|
||||
ui: { setStatus: (key: string, text: string | undefined) => void };
|
||||
calls: { key: string; text: string | undefined }[];
|
||||
/** 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; text: string | undefined }[] = [];
|
||||
const calls: {
|
||||
key: string;
|
||||
content: string[] | undefined;
|
||||
placement?: string;
|
||||
}[] = [];
|
||||
return {
|
||||
calls,
|
||||
ui: {
|
||||
setStatus(key, text) {
|
||||
calls.push({ key, text });
|
||||
theme,
|
||||
setWidget(key, content, options) {
|
||||
calls.push({ key, content, placement: options?.placement });
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -38,9 +63,72 @@ 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: setStatus must never be called.
|
||||
// hasUI false: setWidget must never be called.
|
||||
const footer = createPipelineFooter({ hasUI: false });
|
||||
footer.setPipeline("pygienium smoke", [
|
||||
{ label: "Recon", status: "pending" },
|
||||
@@ -52,28 +140,31 @@ describe("createPipelineFooter", () => {
|
||||
expect(footer.getTitle()).toBe("pygienium smoke");
|
||||
});
|
||||
|
||||
it("renders the full pipeline with the cursor running and clears on done", () => {
|
||||
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 setStatus call, under the canonical key, listing every phase:
|
||||
// cursor gets `▶ <label>`, the rest are glued `…<label>`.
|
||||
// One setWidget call, under the canonical key, placement belowEditor.
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.key).toBe(FOOTER_STATUS_KEY);
|
||||
expect(calls[0]?.text).toContain("pygienium smoke");
|
||||
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} Recon`);
|
||||
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Scanning`);
|
||||
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}Fixing`);
|
||||
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?.text).toBeUndefined();
|
||||
const last = calls[calls.length - 1]!;
|
||||
expect(last.content).toBeUndefined();
|
||||
expect(last.placement).toBe("belowEditor");
|
||||
expect(footer.getItems()).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -103,8 +194,6 @@ describe("createPipelineFooter", () => {
|
||||
0,
|
||||
);
|
||||
footer.setCursor(0); // recon running
|
||||
// Simulate analysis completing (cursor was already moved there) then
|
||||
// jumping to fix: a completed phase must stay complete, not revert.
|
||||
footer.setItem(0, "complete");
|
||||
footer.setCursor(1); // analysis running
|
||||
footer.setItem(1, "complete");
|
||||
@@ -122,32 +211,26 @@ describe("createPipelineFooter", () => {
|
||||
"pygienium comments",
|
||||
footerPhaseItems(scanPhaseIds(), PHASE_LABELS),
|
||||
);
|
||||
// No cursor: all pending. Now a gate-skip marks every phase skipped,
|
||||
// mirroring the runner's gate branch.
|
||||
for (let i = 0; i < footer.getItems().length; i++) {
|
||||
footer.setItem(i, "skipped" as ItemStatus);
|
||||
footer.setItem(i, "skipped");
|
||||
}
|
||||
expect(footer.getItems().every((it) => it.status === "skipped")).toBe(true);
|
||||
});
|
||||
|
||||
it("can be disabled so it never touches the status slot", () => {
|
||||
it("can be disabled so it never touches the widget slot", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({
|
||||
ui,
|
||||
hasUI: true,
|
||||
enabled: false,
|
||||
});
|
||||
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 setStatus call (used by /pygienium-all
|
||||
// enabled:false suppresses every setWidget call (used by /pygienium-all
|
||||
// which owns its own footer).
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes under a custom status key (all-run owns its slot)", () => {
|
||||
it("writes under a custom widget key (all-run owns its slot)", () => {
|
||||
const { ui, calls } = stubUi();
|
||||
const footer = createPipelineFooter({
|
||||
ui,
|
||||
@@ -163,10 +246,12 @@ describe("createPipelineFooter", () => {
|
||||
0,
|
||||
);
|
||||
expect(calls[0]?.key).toBe("pygienium-all");
|
||||
expect(calls[0]?.text).toContain("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(calls[0]?.text).toContain(`${FOOTER_GLYPH.running} comments`);
|
||||
expect(calls[0]?.text).toContain(`${FOOTER_GLYPH.pending}dead-code`);
|
||||
expect(lines[1]).toBe(`accent:• ${FOOTER_MARKER.running} 1. comments`);
|
||||
expect(lines[2]).toBe(`dim:• ${FOOTER_MARKER.pending} 2. dead-code`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user