/** * wishlist-overlay.test.tsx — Tests for the WishlistOverlay Ink component. * * Verifies: * - Renders as a modal with item count in label * - Renders item list * - Shows empty state when no items * - Renders keybind hints in footer * - A key shows input for adding new items * - D/X key deletes the selected item * - Escape triggers onClose callback * - W key triggers onClose callback * - Shift+Up/Down reorders items */ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; import React from "react"; import { render, renderToString } from "ink"; import { PassThrough } from "node:stream"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { addItem, loadWishlist } from "../lib/wishlist-store"; import { WishlistOverlay } from "./wishlist-overlay"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestStreams() { const stdout = new PassThrough() as unknown as NodeJS.WriteStream; (stdout as any).columns = 120; (stdout as any).rows = 40; const stdin = new PassThrough() as unknown as NodeJS.ReadStream; (stdin as any).isTTY = true; (stdin as any).setRawMode = () => stdin; (stdin as any).ref = () => stdin; (stdin as any).unref = () => stdin; return { stdin, stdout }; } function renderLive(element: React.ReactElement) { const { stdin, stdout } = createTestStreams(); const chunks: string[] = []; stdout.on("data", (chunk: Buffer) => { chunks.push(chunk.toString()); }); const instance = render(element, { stdout, stdin, debug: true, exitOnCtrlC: false, patchConsole: false, }); return { instance, stdin: stdin as any as PassThrough, getOutput: () => chunks.join(""), cleanup: async () => { instance.unmount(); await instance.waitUntilExit(); }, }; } let tempRoot: string; beforeEach(() => { tempRoot = mkdtempSync(join(tmpdir(), "woco-overlay-test-")); }); afterEach(() => { try { rmSync(tempRoot, { recursive: true, force: true }); } catch { // ignore } }); // --------------------------------------------------------------------------- // Static render tests // --------------------------------------------------------------------------- describe("WishlistOverlay (static rendering)", () => { test("renders Wishlist label with item count", () => { const output = renderToString( {}} /> ); expect(output).toContain("Wishlist"); expect(output).toContain("0 items"); }); test("renders item count when items exist", () => { addItem(tempRoot, "Item A"); addItem(tempRoot, "Item B"); addItem(tempRoot, "Item C"); const output = renderToString( {}} /> ); expect(output).toContain("3 items"); }); test("renders empty state message", () => { const output = renderToString( {}} /> ); expect(output).toContain("No wishlist items"); }); test("renders item text", () => { addItem(tempRoot, "Buy new keyboard"); const output = renderToString( {}} /> ); expect(output).toContain("Buy new keyboard"); }); test("renders keybind footer", () => { const output = renderToString( {}} /> ); expect(output).toContain("add"); expect(output).toContain("delete"); expect(output).toContain("close"); }); }); // --------------------------------------------------------------------------- // Keybind tests (live render) // --------------------------------------------------------------------------- describe("WishlistOverlay keybinds", () => { test("Escape triggers onClose", async () => { const onClose = mock(() => {}); const { stdin, cleanup } = renderLive( ); await new Promise((r) => setTimeout(r, 50)); stdin.write("\x1b"); await new Promise((r) => setTimeout(r, 50)); expect(onClose).toHaveBeenCalled(); await cleanup(); }); test("W key triggers onClose", async () => { const onClose = mock(() => {}); const { stdin, cleanup } = renderLive( ); await new Promise((r) => setTimeout(r, 50)); stdin.write("w"); await new Promise((r) => setTimeout(r, 50)); expect(onClose).toHaveBeenCalled(); await cleanup(); }); test("D key deletes the selected item", async () => { addItem(tempRoot, "Keep"); addItem(tempRoot, "Delete me"); const { stdin, getOutput, cleanup } = renderLive( {}} /> ); await new Promise((r) => setTimeout(r, 50)); // Move down to second item stdin.write("\x1b[B"); await new Promise((r) => setTimeout(r, 50)); // Delete stdin.write("d"); await new Promise((r) => setTimeout(r, 100)); // Check that item was deleted from disk const items = loadWishlist(tempRoot); expect(items.length).toBe(1); expect(items[0].text).toBe("Keep"); await cleanup(); }); test("X key also deletes the selected item", async () => { addItem(tempRoot, "Item to delete"); const { stdin, cleanup } = renderLive( {}} /> ); await new Promise((r) => setTimeout(r, 50)); stdin.write("x"); await new Promise((r) => setTimeout(r, 100)); const items = loadWishlist(tempRoot); expect(items.length).toBe(0); await cleanup(); }); test("A key enters add mode and shows input prompt", async () => { const { stdin, getOutput, cleanup } = renderLive( {}} /> ); await new Promise((r) => setTimeout(r, 50)); stdin.write("a"); await new Promise((r) => setTimeout(r, 100)); const output = getOutput(); // In add mode, the footer should show Enter/Esc hints expect(output).toContain("save"); await cleanup(); }); test("submitting new item in add mode adds it", async () => { const { stdin, cleanup } = renderLive( {}} /> ); await new Promise((r) => setTimeout(r, 50)); // Enter add mode stdin.write("a"); await new Promise((r) => setTimeout(r, 50)); // Type item text stdin.write("New wishlist idea"); await new Promise((r) => setTimeout(r, 50)); // Submit with Enter stdin.write("\r"); await new Promise((r) => setTimeout(r, 100)); const items = loadWishlist(tempRoot); expect(items.length).toBe(1); expect(items[0].text).toBe("New wishlist idea"); await cleanup(); }); test("Escape in add mode cancels without adding", async () => { const onClose = mock(() => {}); const { stdin, cleanup } = renderLive( ); await new Promise((r) => setTimeout(r, 50)); // Enter add mode stdin.write("a"); await new Promise((r) => setTimeout(r, 50)); // Type something stdin.write("draft"); await new Promise((r) => setTimeout(r, 50)); // Cancel with Escape stdin.write("\x1b"); await new Promise((r) => setTimeout(r, 100)); // Should not have added the item const items = loadWishlist(tempRoot); expect(items.length).toBe(0); // Should NOT have closed the overlay (Esc in add mode cancels add, not overlay) expect(onClose).not.toHaveBeenCalled(); await cleanup(); }); test("delete does nothing on empty list", async () => { const onClose = mock(() => {}); const { stdin, cleanup } = renderLive( ); await new Promise((r) => setTimeout(r, 50)); stdin.write("d"); await new Promise((r) => setTimeout(r, 50)); // Should not crash, onClose should not be called expect(onClose).not.toHaveBeenCalled(); await cleanup(); }); });