Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/ephemlets/__tests__/panel.test.ts

Raw
import type {
	ExtensionAPI,
	ExtensionContext,
	Theme,
} from "@earendil-works/pi-coding-agent";
import {
	type KeybindingsManager,
	type OverlayOptions,
	type TUI,
	type TuiMouseEvent,
	visibleWidth,
} from "@earendil-works/pi-tui";
import { describe, expect, it, vi } from "vitest";
import { EphemletPanel, PanelHost, panelGeometry } from "../src/panel.ts";
import { EphemletStore } from "../src/store.ts";
import type { Json } from "../src/types.ts";

const theme = { fg: (_color: string, text: string) => text } as Theme;
const keybindings = {} as KeybindingsManager;
const api = {} as ExtensionAPI;
function mouse(
	x: number,
	y: number,
	type: TuiMouseEvent["type"] = "click",
): TuiMouseEvent {
	return {
		x,
		y,
		screenX: x,
		screenY: y,
		width: 60,
		height: 30,
		type,
		button: "left",
		shift: false,
		alt: false,
		ctrl: false,
	};
}
function fixture() {
	const store = new EphemletStore(() => {});
	const terminal = { columns: 120, rows: 32 };
	const tui = { terminal, requestRender() {} } as unknown as TUI;
	const ctx = { mode: "tui", hasUI: true } as ExtensionContext;
	const closed: string[] = [];
	const saved: string[] = [];
	let hidden = 0;
	const panel = new EphemletPanel(
		api,
		store,
		() => ctx,
		tui,
		theme,
		keybindings,
		{
			changed: () => panel.refresh(),
			close: (id) => {
				closed.push(id);
				store.delete(id);
				panel.refresh();
			},
			save: (id) => saved.push(id),
			hide: () => {
				hidden++;
				store.view(store.selected, false);
			},
		},
	);
	const put = (source: string, state: Json = null) => {
		const record = store.put({
			title: "Native UI",
			purpose: "Interaction",
			kind: "interactive",
			source,
			state,
		});
		store.view(record.id, true);
		panel.refresh();
		return record;
	};
	return {
		store,
		terminal,
		tui,
		panel,
		put,
		closed,
		saved,
		hidden: () => hidden,
	};
}
const counter = `export default context => ({
 render: width => [String(context.state?.value ?? 0)], invalidate() {},
 handleInput(data) { context.setState({ value: data }); },
 handleMouse(event) { context.setState({ value: event.x + ":" + event.y }); return { handled: true }; },
 dispose() {}
});`;

describe("ephemlet responsive overlay", () => {
	it("positions landscape right and portrait top, including small terminals", () => {
		expect(panelGeometry(120, 32)).toEqual({
			anchor: "top-right",
			width: 54,
			height: 28,
		});
		expect(panelGeometry(60, 50)).toEqual({
			anchor: "top-left",
			width: 60,
			height: 24,
		});
		expect(panelGeometry(2, 2)).toEqual({
			anchor: "top-left",
			width: 2,
			height: 1,
		});
	});
	it("routes native keyboard and local mouse coordinates; fits resized content", async () => {
		const f = fixture();
		const record = f.put(counter, { value: "initial" });
		try {
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("initial"),
			);
			f.panel.handleInput("x");
			expect(f.store.get(record.id).state).toEqual({ value: "x" });
			f.panel.handleMouse(mouse(3, 4));
			expect(f.store.get(record.id).state).toEqual({ value: "2:1" });
			f.terminal.columns = 30;
			f.terminal.rows = 50;
			const lines = f.panel.render(30);
			expect(lines.length).toBeLessThanOrEqual(24);
			expect(lines.every((line) => visibleWidth(line) <= 30)).toBe(true);
		} finally {
			f.panel.dispose();
		}
	});
	it("switches ephemlets by keyboard/mouse and retains durable state", async () => {
		const f = fixture();
		const first = f.put(counter, { value: "first" });
		const second = f.put(counter, { value: "second" });
		try {
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("second"),
			);
			f.panel.handleInput("\x1b[1;3D");
			expect(f.store.selected).toBe(first.id);
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("first"),
			);
			f.panel.handleMouse(mouse(22, 1));
			expect(f.store.selected).toBe(second.id);
			f.panel.handleInput("\x1b");
			expect(f.hidden()).toBe(1);
			expect(f.store.records.size).toBe(2);
		} finally {
			f.panel.dispose();
		}
	});
	it("chrome has independent Close/Save and keyboard focus", async () => {
		const f = fixture();
		const record = f.put(counter, { value: "state" });
		try {
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("state"),
			);
			f.panel.handleInput("\x13");
			expect(f.saved).toEqual([record.id]);
			expect(f.store.records.size).toBe(1);
			f.panel.handleMouse(mouse(10, 1));
			expect(f.saved).toEqual([record.id, record.id]);
			f.panel.handleInput("\x1b[9;3u");
			f.panel.handleInput("\r");
			expect(f.closed).toEqual([record.id]);
			expect(f.store.records.size).toBe(0);
		} finally {
			f.panel.dispose();
		}
	});
	it("aborts and disposes native resources when source changes or closes", async () => {
		const f = fixture();
		const record = f.put(
			'export default c => ({render: () => ["mounted"], invalidate() {}, dispose() { c.pi.disposed = c.signal.aborted; }})',
		);
		const scopedApi = api as unknown as { disposed?: boolean };
		try {
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("mounted"),
			);
			f.store.delete(record.id);
			f.panel.refresh();
			expect(scopedApi.disposed).toBe(true);
		} finally {
			f.panel.dispose();
			delete scopedApi.disposed;
		}
	});
	it("retains last usable revision after factory or first-render failure", async () => {
		const f = fixture();
		const first = f.put(counter, { value: "usable" });
		try {
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("usable"),
			);
			f.store.put({
				...first,
				expectedRevision: first.revision,
				source: 'export default () => { throw new Error("bad factory"); }',
			});
			f.panel.refresh();
			await vi.waitFor(() =>
				expect(f.store.get(first.id).source).toBe(counter),
			);
			expect(f.store.get(first.id)).toMatchObject({
				status: "failed",
				state: { value: "usable" },
				revision: 3,
			});
			await vi.waitFor(() =>
				expect(f.panel.render(54).join("\n")).toContain("usable"),
			);
		} finally {
			f.panel.dispose();
		}
	});
	it("cleans failed widget registration and allows retry", () => {
		const f = fixture();
		f.put(counter);
		const mounted: { render(width: number): string[] }[] = [];
		const hide = vi.fn();
		const notify = vi.fn();
		let calls = 0;
		f.tui.showOverlay = (component) => {
			mounted.push(component);
			return {
				hide,
				setHidden() {},
				isHidden: () => false,
				focus() {},
				unfocus() {},
				isFocused: () => false,
				getBounds: () => undefined,
			};
		};
		const ctx = {
			mode: "tui",
			ui: {
				notify,
				setWidget: (
					_key: string,
					factory: ((tui: TUI, theme: Theme) => unknown) | undefined,
				) => {
					if (!factory) return;
					factory(f.tui, theme);
					if (++calls === 1) throw new Error("Widget registration failed");
				},
			},
		} as unknown as ExtensionContext;
		const host = new PanelHost(api, f.store, () => ctx, {
			changed() {},
			close() {},
			save() {},
			hide() {},
		});
		try {
			host.refresh();
			expect(notify).toHaveBeenCalledWith(
				"Ephemlet panel: Error: Widget registration failed",
				"error",
			);
			expect(hide).toHaveBeenCalledTimes(1);
			expect(mounted[0]?.render(54)).toEqual([]);
			host.refresh();
			expect(calls).toBe(2);
			expect(mounted[1]?.render(54).length).toBeGreaterThan(0);
			host.dispose();
			expect(mounted[1]?.render(54)).toEqual([]);
			expect(hide).toHaveBeenCalledTimes(2);
		} finally {
			host.dispose();
			f.panel.dispose();
		}
	});
	it("hides through overlay handle rather than deleting/reloading", async () => {
		const f = fixture();
		f.put(counter);
		let calls = 0;
		let isHidden = false;
		let options: OverlayOptions | undefined;
		let widget: { dispose?(): void } | undefined;
		f.tui.showOverlay = (_component, nextOptions) => {
			options = nextOptions;
			return {
				setHidden(value: boolean) {
					isHidden = value;
				},
				isHidden: () => isHidden,
				focus() {},
				unfocus() {},
				isFocused: () => false,
				getBounds: () => undefined,
				hide() {},
			};
		};
		const ctx = {
			mode: "tui",
			ui: {
				setWidget: (
					_key: string,
					factory:
						| ((tui: TUI, theme: Theme) => { dispose?(): void })
						| undefined,
				) => {
					widget?.dispose?.();
					widget = undefined;
					if (factory) {
						calls++;
						widget = factory(f.tui, theme);
					}
				},
			},
		} as unknown as ExtensionContext;
		const host = new PanelHost(api, f.store, () => ctx, {
			changed() {},
			close() {},
			save() {},
			hide() {},
		});
		try {
			host.refresh();
			f.store.view(f.store.selected, false);
			host.refresh();
			expect(isHidden).toBe(true);
			f.store.view(f.store.selected, true);
			host.refresh(true);
			expect(isHidden).toBe(false);
			expect(calls).toBe(1);
			expect(options?.anchor).toBe("top-right");
			f.terminal.columns = 40;
			f.terminal.rows = 50;
			expect(options?.anchor).toBe("top-left");
			expect(options?.width).toBe(40);
		} finally {
			host.dispose();
			f.panel.dispose();
		}
	});
});