/** * Show-row height regression — My Shows depth-0 rows must stay exactly one * line tall: marker + show title + episode count (+ watchlist dot). The * flexible title carries `wrapMode="none"` + `truncate` (middle-ellipsis: * head and tail of the title stay visible) and the fixed-width cells carry * `flexShrink={0}`, so Yoga can never shrink them and wrap the row — a * wrapped row grows to 2+ lines and the episode count + watchlist dot shift * below the title while scrolling (the original bug). Same guard for the * 20%-wide parent-pane shows list at depth ≥1. * * Rendered at 70 columns so the 35-col current pane / 14-col parent pane are * narrow enough to force truncation on the long title; at the default * 100-col/50-col pane the same rows show everything in full. */ import { describe, test, expect, afterAll } from "bun:test"; import type { JSX } from "solid-js"; import { testRender } from "@opentui/solid"; import { ThemeProvider } from "../src/context/ThemeContext"; import { PaneRow } from "../src/components/PaneRow"; type Frame = { cols: number; lines: { spans: { text: string }[] }[] }; function frameText(spans: Frame): string[] { return spans.lines.map((l) => l.spans.map((s) => s.text).join("")); } const LONG_TITLE = "Out of Whiskey and Reaching for the Rotgut (Members Only #338)"; // The exact depth-0 row shape MyShowsPage renders: marker + title + count + // watchlist dot. Static text, no store hooks — pure layout probe. const ShowRowFixed = () => ( {LONG_TITLE} (123) ); // Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title // wraps at the shrunken width and pushes the count + dot onto wrapped lines. const ShowRowNaive = () => ( {LONG_TITLE} (123) ); // The depth-1 parent-pane shows-list row (marker + title + count). const ParentRowFixed = () => ( {LONG_TITLE} (123) ); const ParentRowNaive = () => ( {LONG_TITLE} (123) ); async function renderRow( row: () => JSX.Element, pane: "current" | "parent", width = 70, ): Promise<{ lines: string[]; destroy: () => Promise }> { const setup = await testRender( () => ( ), { width, height: 10, useThread: false }, ); // ThemeProvider mounts children only once the theme resolves; poll for // the header row so the captured frame is a mounted PaneRow. let lines: string[] | null = null; for (let i = 0; i < 40 && !lines; i++) { await setup.renderOnce(); const frame = setup.captureSpans() as unknown as Frame; const ls = frameText(frame); if (ls.some((l) => l.includes("List"))) lines = ls; else await new Promise((r) => setTimeout(r, 100)); } if (!lines) throw new Error("PaneRow did not render before timeout"); return { lines, destroy: async () => { setup.renderer.destroy(); }, }; } const cleanups: (() => void | Promise)[] = []; afterAll(async () => { for (const c of cleanups) { try { await c(); } catch { // renderer already torn down — ignore } } }); describe("show row height in the current pane (70-wide → 35-col pane)", () => { test("long title stays on one line — middle-ellipsis keeps head AND tail, count + dot stay aligned", async () => { const { lines, destroy } = await renderRow(ShowRowFixed, "current"); cleanups.push(destroy); // Middle-ellipsis: the title head and its tail both survive, on a // single line (end-truncation would drop the tail). expect(lines.filter((l) => l.includes("Out of Wh"))).toHaveLength(1); expect(lines.filter((l) => l.includes("#338)"))).toHaveLength(1); // The count and watchlist dot sit on that same line — nothing wrapped. const aligned = lines.filter( (l) => l.includes("Out of Wh") && l.includes("#338)") && l.includes("(123)") && l.includes("●"), ); expect(aligned).toHaveLength(1); // Row occupies exactly 1 content line below the header. const content = lines.filter( (l) => l.includes("Out of Wh") || l.includes("(123)"), ); expect(content).toHaveLength(1); }); test("naive row (pre-fix props) wraps the title — the regression the test guards", async () => { const { lines, destroy } = await renderRow(ShowRowNaive, "current"); cleanups.push(destroy); // The title's wrapped fragments span 3 frame lines instead of 1 — // every row below shifts while scrolling. const titleFragments = lines.filter( (l) => l.includes("Out of Whiskey") || l.includes("Rotgut") || l.includes("#338)"), ); expect(titleFragments).toHaveLength(3); }); }); describe("show row in the parent pane (70-wide → 14-col pane)", () => { test("long title stays on one line with count aligned", async () => { const { lines, destroy } = await renderRow(ParentRowFixed, "parent"); cleanups.push(destroy); // The 14-col slot truncates the title to a head stub (too narrow for // head + tail), but the row stays one line and the count pins to it. expect(lines.filter((l) => l.includes("O..."))).toHaveLength(1); expect( lines.filter((l) => l.includes("O...") && l.includes("(123)")), ).toHaveLength(1); }); test("naive parent row wraps the title — the regression the test guards", async () => { const { lines, destroy } = await renderRow(ParentRowNaive, "parent"); cleanups.push(destroy); const titleFragments = lines.filter( (l) => l.includes("Out of") || l.includes("Whiskey") || l.includes("Rotgut") || l.includes("#3"), ); expect(titleFragments.length).toBeGreaterThan(1); }); });