ui cleanup

This commit is contained in:
2026-08-07 13:38:32 -04:00
parent c8d29ed59d
commit 85cb9fba26
24 changed files with 1839 additions and 1375 deletions

View File

@@ -0,0 +1,38 @@
/**
* Audio backend dispose regression test.
*
* The `q` (quit) action routes through `process.exit(0)`, which bypasses
* Solid's onCleanup (where useAudio's onCleanup disposes the backend). To
* keep spawned players (mpv/ffplay/afplay) from surviving the host, useAudio
* registers a `process.on("exit")` handler that synchronously disposes the
* backend. The exit handler's whole job is "kill the child process", so this
* test pins the contract directly: a backend holding a real spawned subprocess
* must have killed it once `dispose()` returns.
*
* Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the
* player process, injected into the (private) `proc` slot of an MpvBackend —
* mpv/ffplay/afplay all share the identical kill-on-dispose pattern, so
* exercising one is enough to guard the family.
*/
import { test, expect } from "bun:test";
import { MpvBackend } from "../src/utils/audio-player";
test("MpvBackend.dispose() kills the spawned child process", async () => {
const backend = new MpvBackend();
// Inject a real long-lived subprocess as if mpv had been spawned.
const child = Bun.spawn(["sleep", "60"], {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
(backend as unknown as { proc: typeof child }).proc = child;
// Sanity: the child is alive.
expect(child.killed).toBe(false);
backend.dispose();
// dispose() sent SIGTERM (proc.kill()); wait for the child to exit.
await child.exited;
expect(child.killed).toBe(true);
});

View File

@@ -11,8 +11,9 @@
* enter its content; `swipe-prev` (h) stays inert (out of the panes).
* • Depth-tab content: `swipe-next` (l) at depth 0 emits `open` (drill);
* `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the tab root.
* • Fixed-pane tabs (Search/Player, special): `h`/`l` swipe [1, paneCount];
* `h` on the first pane stays — never overflows to the tab root.
* • Every tab is a depth-tab: `swipe-next` (l) at depth 0 emits `open`
* (drill); `swipe-prev` (h) pops depth 1→0 and, at depth 0, returns to the
* tab root. Player has no deeper drill (single now-playing pane).
* • Digit keys (`tab-goto-N`), `tab-next` (`]`), `tab-prev` (`[`) switch
* tabs and preserve focus context (root stays root for depth-tabs, content
* stays content).
@@ -218,33 +219,50 @@ test("dispatch('swipe-prev') at depth 0 returns focus to the tab root", () => {
});
});
test("dispatch('swipe-prev') on a fixed-pane tab at pane 1 stays (no tab overflow)", () => {
test("dispatch('swipe-prev') on Search at depth 0 returns to the tab root", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane
nav.setActiveTab(TABS.SEARCH); // depth-tab, query root
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
dispatch("swipe-prev");
// special tab: h on the first content pane does not return to the root.
expect(nav.atRootTab()).toBe(false);
// h at depth 0 returns to the tab root — so Search isn't a dead end.
expect(nav.atRootTab()).toBe(true);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
// a second h at the root is inert (out of the panes).
dispatch("swipe-prev");
expect(nav.atRootTab()).toBe(true);
});
});
test("dispatch('swipe-prev') on a fixed-pane tab at pane > 1 swipes leftwards", () => {
test("dispatch('swipe-prev') on the single-pane Player tab returns to the tab root", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // 3 panes
nav.setActiveTab(TABS.PLAYER); // depth-tab, single now-playing pane
nav.enterTabContent();
nav.swipe(1, 3);
nav.swipe(1, 3);
expect(nav.activePane()).toBe(3);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
dispatch("swipe-prev");
expect(nav.activePane()).toBe(2);
expect(nav.atRootTab()).toBe(true);
});
});
test("dispatch('swipe-prev') at depth 1 (results) pops to depth 0 (query)", () => {
withHarness(({ nav, dispatch }) => {
nav.setActiveTab(TABS.SEARCH); // depth-tab: query(0) → results(1)
nav.enterTabContent();
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
expect(nav.currentDepth()).toBe(1);
dispatch("swipe-prev");
expect(nav.currentDepth()).toBe(0);
expect(nav.atRootTab()).toBe(false); // h at depth>0 stays in content
});
});
// ── Acceptance: digit keys switch tabs and keep focus context ────────────────
test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open", () => {
test("tab-goto-N from the root keeps depth-tabs at the root", () => {
withHarness(({ nav, dispatch }) => {
// focus starts on the tab root.
expect(nav.atRootTab()).toBe(true);
@@ -259,11 +277,12 @@ test("tab-goto-N from the root keeps depth-tabs at the root; special tabs open",
expect(nav.tabCursor()).toBe(TABS.MYSHOWS);
expect(nav.atRootTab()).toBe(true);
// fixed-pane tab is special: switching from the root opens its content.
// every tab is a depth-tab now: switching to Search from the root
// keeps the root too (Enter/l opens content).
dispatch("tab-goto-4"); // → Search
expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.tabCursor()).toBe(TABS.SEARCH);
expect(nav.atRootTab()).toBe(false);
expect(nav.atRootTab()).toBe(true);
});
});
@@ -276,7 +295,7 @@ test("tab-goto-N from content keeps focus in the active tab's content", () => {
expect(nav.activeTab()).toBe(TABS.DISCOVER);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
dispatch("tab-goto-4"); // → Search (fixed-pane) lands its current pane
dispatch("tab-goto-4"); // → Search (depth-tab) lands its current pane
expect(nav.activeTab()).toBe(TABS.SEARCH);
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);

View File

@@ -8,7 +8,8 @@
* the tab list is the CURRENT pane (nothing above it). `enterTabContent()`
* slides the tab into UP and puts focus on the content; `backToTabRoot()`
* returns to the root. Only depth-tabs participate (`atRootTab()` is false
* for the fixed-pane Search/Player tabs).
* for the fixed-pane Search/Player tabs (they clear `atRootTab` on switch
* and regain it via `backToTabRoot`, the `h`-back-up path).
* • the root tab list is a normal list: `tabCursor` is independent of
* `activeTab`; moveTabCursor moves it (clamped), activateTabCursor opens
* the hovered tab + enters content, and direct tab switches re-sync it.
@@ -23,7 +24,7 @@ import {
DEPTH_CENTER_PANE,
NavMode,
} from "../src/context/navigation-store";
import { TABS, TabPaneCount } from "../src/utils/navigation";
import { TABS } from "../src/utils/navigation";
/** Build a fresh nav graph inside a reactive root and run `fn` against it.
* Disposes the root afterwards so effects/signals don't leak between tests. */
@@ -115,15 +116,22 @@ test("tab switch keeps focus context: in content it stays in content", () => {
});
});
test("switching to a Search/Player tab leaves the root (special content)", () => {
test("switching to a Search/Player tab keeps the root (depth-tab)", () => {
withNav((nav) => {
// at root, opening Search is special: atRootTab() reports false because
// Search has its own content and never renders the tab-list root view.
// at root, opening Search keeps the root: every tab is a depth-tab now,
// so Enter/l is required to drop into content. `h`-back-up still works.
nav.setActiveTab(TABS.SEARCH);
expect(nav.atRootTab()).toBe(false);
expect(nav.atRootTab()).toBe(true);
nav.enterTabContent();
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
expect(nav.atRootTab()).toBe(false);
nav.backToTabRoot();
expect(nav.atRootTab()).toBe(true);
// Player is also a depth-tab now.
nav.setActiveTab(TABS.PLAYER);
expect(nav.atRootTab()).toBe(true);
nav.enterTabContent();
expect(nav.atRootTab()).toBe(false);
});
});
@@ -217,43 +225,30 @@ test("tab-switch resets mode/visual/command state", () => {
});
// ── swipe clamps to [1, paneCount] (no pane-0 tab slot) ──────────────────────
test("swipe on a fixed-pane tab stays within [1, paneCount]", () => {
test("Search is a depth-tab: query root drills to results and back", () => {
withNav((nav) => {
nav.setActiveTab(TABS.SEARCH); // fixed-pane, TabPaneCount = 3
expect(TabPaneCount[TABS.SEARCH]).toBe(3);
nav.setActiveTab(TABS.SEARCH);
expect(nav.isDepthTab()).toBe(true);
expect(nav.topFrame()?.kind).toBe("search:query");
nav.enterTabContent();
expect(nav.activePane()).toBe(1);
// swipe left stays at 1 (no pane 0).
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
// swipe right up through the columns, then hold the upper bound.
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2);
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(3);
nav.swipe(1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(3); // never exceeds paneCount
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(2);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
nav.swipe(-1, TabPaneCount[TABS.SEARCH]);
expect(nav.activePane()).toBe(1);
expect(nav.currentDepth()).toBe(0);
// Enter on the query submits → push a results frame.
nav.pushDepth({ kind: "search:results", ctx: "podcast", focus: 0 });
expect(nav.currentDepth()).toBe(1);
// h at depth 1 pops back to the query.
expect(nav.popDepth()).toBe(true);
expect(nav.currentDepth()).toBe(0);
});
});
test("swipe on a single-pane fixed tab stays at its one content pane", () => {
test("Player is a single-depth depth-tab (now-playing only)", () => {
withNav((nav) => {
nav.setActiveTab(TABS.PLAYER); // single-pane
expect(TabPaneCount[TABS.PLAYER]).toBe(1);
nav.enterTabContent(); // lands on its one content pane (1)
expect(nav.activePane()).toBe(1);
nav.swipe(1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(1); // upper bound
nav.swipe(-1, TabPaneCount[TABS.PLAYER]);
expect(nav.activePane()).toBe(1); // lower bound — never drops to a tab 0
nav.setActiveTab(TABS.PLAYER);
expect(nav.isDepthTab()).toBe(true);
expect(nav.topFrame()?.kind).toBe("player:nowplaying");
nav.enterTabContent();
expect(nav.currentDepth()).toBe(0); // no deeper drill
expect(nav.popDepth()).toBe(false); // noop at depth 0
});
});

View File

@@ -1,8 +1,8 @@
/**
* yazi-pages-depth.test.ts — task 03 page contract tests.
*
* The four depth-stack list tabs (Feed / MyShows / Discover / Settings) all
* render through `<YaziPaneRow>` with the parent pane reading the
* Every depth-stack tab (Feed / MyShows / Discover / Search / Player /
* Settings) renders through `<YaziPaneRow>` with the parent pane reading the
* previous-depth frame's list (blank placeholder at depth 0). Their `open()`
* action calls `nav.pushDepth(frame)` to drill and the Shell calls
* `nav.popDepth()` on `h`. This file exercises the nav-store contract those
@@ -37,8 +37,15 @@ function withNav(fn: (nav: ReturnType<typeof createNavigation>) => void) {
});
}
/** The depth-tabs that must render via <YaziPaneRow> (task 03 conversion). */
const CONVERTED_TABS = [TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS];
/** The depth-tabs that render via <YaziPaneRow> (task 03 conversion). */
const CONVERTED_TABS = [
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SEARCH,
TABS.PLAYER,
TABS.SETTINGS,
];
for (const tab of CONVERTED_TABS) {
const name = TABS[tab];
@@ -54,7 +61,11 @@ for (const tab of CONVERTED_TABS) {
// drill (l): page open() pushes a child frame — parent becomes
// the previous-depth list.
const child: DepthFrame = { kind: `${name.toLowerCase()}:child`, ctx: "c1", focus: 0 };
const child: DepthFrame = {
kind: `${name.toLowerCase()}:child`,
ctx: "c1",
focus: 0,
};
nav.pushDepth(child);
nav.setActivePane(DEPTH_CENTER_PANE);
expect(nav.currentDepth()).toBe(1);
@@ -65,7 +76,11 @@ for (const tab of CONVERTED_TABS) {
// drill again (l): push a second child — parent shows the first
// child's list (the chain Settings exercises: sections→items→editor).
const grandchild: DepthFrame = { kind: `${name.toLowerCase()}:grand`, ctx: "g1", focus: 0 };
const grandchild: DepthFrame = {
kind: `${name.toLowerCase()}:grand`,
ctx: "g1",
focus: 0,
};
nav.pushDepth(grandchild);
expect(nav.currentDepth()).toBe(2);
expect(nav.depthStack()).toHaveLength(3);
@@ -97,9 +112,16 @@ for (const tab of CONVERTED_TABS) {
});
}
// ── DEPTH_TABS covers exactly the four converted pages ───────────────────────
test("DEPTH_TABS is exactly the four converted list tabs", () => {
// ── DEPTH_TABS covers exactly the depth-stack pages ───────────────────────
test("DEPTH_TABS is exactly the depth-stack tabs", () => {
expect([...DEPTH_TABS].sort()).toEqual(
[TABS.FEED, TABS.MYSHOWS, TABS.DISCOVER, TABS.SETTINGS].sort(),
[
TABS.FEED,
TABS.MYSHOWS,
TABS.DISCOVER,
TABS.SEARCH,
TABS.PLAYER,
TABS.SETTINGS,
].sort(),
);
});