fix border resizing ui

This commit is contained in:
2026-08-28 19:45:14 -04:00
parent 4ff8aabb51
commit 74158d75d4
21 changed files with 806 additions and 193 deletions

View File

@@ -329,7 +329,7 @@ test("date mode: episodes outside the 60-day window never enter the list", async
expect(store.getFeed(id)!.episodes.length).toBe(600);
});
test("date mode boundary: 25 days in, 70 days out", async () => {
test("date mode: the 5 newest episodes load even outside the date window", async () => {
const store = useFeedStore();
const now = Date.now();
servedEpisodes = [
@@ -344,13 +344,15 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
"Out Window",
]);
// The 70d episode is ~45 days past the 2-week band beyond the oldest
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
// The 70d episode is the second-newest available, so the min-5 floor
// pulls it in despite the 60-day cache window.
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
"Out Window",
]);
});
@@ -369,17 +371,19 @@ test("date mode: a dormant show (nothing in the window or next band) never fetch
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
// The min-5 floor surfaces the show's only 2 episodes; it still cannot
// fetch-more (nothing further exists to load).
expect(store.getFeed(id)!.episodes.length).toBe(2);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
expect(store.getFeed(id)!.episodes.length).toBe(2);
});
test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => {
const store = useFeedStore();
const now = Date.now();
// Both episodes are outside the 60-day window (61d / 65d) but inside the
// 14-day band past its edge (60d → 74d) — fetch-more reveals them.
// Both episodes are outside the 60-day window (61d / 65d); the min-5
// floor loads them at subscribe time regardless.
servedEpisodes = [
{ title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() },
{ title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() },
@@ -390,8 +394,9 @@ test("date mode: episodes just outside the window load via the band anchored at
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
expect(store.hasMoreEpisodes(id)).toBe(true);
// The min-5 floor loads both out-of-window episodes immediately.
expect(store.getFeed(id)!.episodes.length).toBe(2);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"Just Out A",

View File

@@ -0,0 +1,101 @@
/**
* pane-layout-store.test.ts — the shared pane-split store: splitPixels
* clamping, border moves that respect per-pane minimum widths, and
* commit() persistence into the app preferences.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (its module-level init reads it).
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-panecfg-"));
// The config dir must be set before the module is evaluated, so the app
// store is loaded dynamically here rather than statically at the top.
const { splitPixels, createPaneLayoutStore, DEFAULT_PANE_SPLITS } = await import(
"../src/stores/pane-layout"
);
const { useAppStore } = await import("../src/stores/app");
beforeAll(async () => {
await useAppStore().whenReady();
});
afterAll(() => {
// Restore pristine preferences so a later file sharing this process
// (bun test reuses the module registry) renders the default split.
useAppStore().updatePreferences({ paneSplit: DEFAULT_PANE_SPLITS });
});
test("default splits mirror the historical 2:5:3 ratio at width 100", () => {
expect(DEFAULT_PANE_SPLITS).toEqual({ left: 0.2, right: 0.7 });
const { leftPx, rightPx } = splitPixels(100, DEFAULT_PANE_SPLITS);
expect(leftPx).toBe(20);
expect(rightPx).toBe(70);
});
test("splitPixels maps splits 1:1 to pixels (ratio exact at every width)", () => {
// Pure fraction→pixel mapping — no minimum enforcement in rendering.
expect(splitPixels(100, { left: 0.6, right: 0.7 })).toEqual({
leftPx: 60,
rightPx: 70,
});
expect(splitPixels(70, { left: 0.2, right: 0.7 })).toEqual({
leftPx: 14,
rightPx: 49,
});
});
test("splitPixels handles zero-width and degenerate terminals", () => {
expect(splitPixels(0, DEFAULT_PANE_SPLITS)).toEqual({ leftPx: 0, rightPx: 0 });
const tiny = splitPixels(40, { left: 0.2, right: 0.7 });
expect(tiny.leftPx).toBe(8);
expect(tiny.rightPx).toBe(28);
});
test("setRight clamps the preview to its minimum", () => {
const store = createPaneLayoutStore();
store.setRight(97, 100); // preview can't shrink below 15
expect(store.splits().right).toBeCloseTo(0.85, 5);
// The parent minimum also holds when the left border is dragged.
store.setLeft(1, 100);
expect(store.splits().left).toBeCloseTo(0.15, 5);
});
test("setLeft moves the border and normalizes stored fractions", () => {
const store = createPaneLayoutStore();
store.setLeft(40, 100);
expect(store.splits().left).toBeCloseTo(0.4, 5);
expect(store.splits().right).toBeCloseTo(0.7, 5);
});
test("setLeft below the parent minimum clamps up", () => {
const store = createPaneLayoutStore();
store.setLeft(5, 100);
expect(store.splits().left).toBeCloseTo(0.15, 5);
});
test("setLeft beyond the current minimum forces the right border right", () => {
const store = createPaneLayoutStore();
store.setLeft(60, 100);
expect(store.splits().left).toBeCloseTo(0.55, 5);
expect(store.splits().right).toBeCloseTo(0.85, 5);
});
test("setRight respects the current and preview minimums", () => {
const store = createPaneLayoutStore();
store.setRight(80, 100);
expect(store.splits().right).toBeCloseTo(0.8, 5);
store.setRight(40, 100); // must not cross below leftPx(20) + minCurrent(30)
expect(store.splits().right).toBeCloseTo(0.5, 5);
});
test("commit persists the split into the app preferences", async () => {
const store = createPaneLayoutStore();
store.setLeft(35, 100);
store.commit();
await useAppStore().whenReady();
expect(useAppStore().state().preferences.paneSplit.left).toBeCloseTo(0.35, 5);
expect(useAppStore().state().preferences.paneSplit.right).toBeCloseTo(0.7, 5);
});

162
tests/pane-resize.test.tsx Normal file
View File

@@ -0,0 +1,162 @@
/**
* pane-resize.test.tsx — dragging the center column's borders actually
* resizes the panes in a rendered PaneRow.
*
* Each pane renders a long run of a unique character (P / C / V). A line
* where all three meet encodes the boundary columns directly: the current
* pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its
* content starts one column in — `leftPx + 1`. Hence
* `leftPx = firstC - 1`, `rightPx = firstV`.
*
* The drag strips overlay the border cells (left strip at [left, left+2),
* right strip at [right-2, right)). The test presses inside a strip and
* drags across the row — the drag bubbles to the row container which moves
* the split, so the panes must re-render at the new columns.
*/
import { test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { usePaneLayout } from "../src/stores/pane-layout";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-"));
process.env.XDG_CONFIG_HOME = configHome;
type Span = { text: string };
type Frame = { cols: number; lines: { spans: Span[] }[] };
interface BoundCols {
left: number;
right: number;
}
function readBounds(frame: Frame): BoundCols {
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
if (!line) throw new Error("pane row did not render");
return { left: line.indexOf("C") - 1, right: line.indexOf("V") };
}
async function renderRow(panes: 2 | 3 = 3) {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
panes={panes}
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
mockMouse: {
drag: (a: number, b: number, c: number, d: number) => Promise<void>;
click: (a: number, b: number) => Promise<void>;
};
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
return setup;
}
/** Reset the shared store to the default split for a deterministic start. */
function resetSplits() {
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
}
const cleanups: (() => void)[] = [];
afterAll(() => {
for (const c of cleanups) c();
// Restore the default split so a later file sharing this process (bun
// test reuses the module registry) renders the default layout.
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
rmSync(configHome, { recursive: true, force: true });
});
test("panes render at the default 20/70 split", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("dragging the left border resizes parent vs current", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the left strip (border at 20 → strip covers 20) and drag
// toward the middle of the row.
await setup.mockMouse.drag(20, 5, 45, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBeGreaterThanOrEqual(44);
expect(after.left).toBeLessThanOrEqual(46);
// Pushing the left border to 45 would shrink the current pane below its
// 30-col minimum (45..70 = 25), so the right border is forced right to
// 75, keeping the current pane at exactly 30 and absorbing the overflow
// in the preview.
expect(after.right).toBe(75);
});
test("dragging the right border resizes current vs preview", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the right strip (border at 69 → strip covers 69) and drag
// toward the right edge of the row.
await setup.mockMouse.drag(69, 5, 90, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.right).toBeGreaterThanOrEqual(84);
expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15
expect(after.left).toBe(20);
});
test("a plain click away from the borders does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
await setup.mockMouse.click(5, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("2-pane rows offer no right border (current fills the row)", async () => {
const setup = await renderRow(2);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
for (let i = 0; i < 10; i++) await setup.renderOnce();
// No 'V' pane: the line runs to the screen edge.
const frame = setup.captureSpans();
const { left } = readBounds(frame);
expect(left).toBe(20);
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
expect(line?.includes("V")).toBe(false);
});

View File

@@ -0,0 +1,59 @@
/** Scratch — verify full-height hover accent line. */
import { test, expect } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-phover3-"));
type Span = { text: string; fg?: { r: number; g: number; b: number; a: number } | null };
type Frame = { cols: number; lines: { spans: Span[] }[] };
test("hover renders accent line on every row", async () => {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
captureCharFrame: () => string;
mockMouse: { moveTo: (x: number, y: number) => Promise<void> };
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
await setup.mockMouse.moveTo(20, 5);
for (let i = 0; i < 3; i++) await setup.renderOnce();
const frame = setup.captureSpans();
let accentRows = 0;
for (let y = 0; y < frame.lines.length; y++) {
const line = frame.lines[y].spans.map((s) => s.text).join("");
// Border column = 20.
const ch = line[20];
const isAccent = frame.lines[y].spans
.filter((s) => s.text.length > 0)
.some((s) => {
const t = s.text;
let c = 0;
// recompute col: approximate by scanning previous spans
return false;
});
if (ch === "│") accentRows++;
console.log(`row ${y}: ${JSON.stringify(line.slice(16, 25))} ch20=${JSON.stringify(ch)}`);
}
console.log("accentRows:", accentRows, "of", frame.lines.length);
expect(accentRows).toBeGreaterThanOrEqual(6);
setup.renderer.destroy();
});