ui cleanup
This commit is contained in:
38
tests/audio-dispose.test.ts
Normal file
38
tests/audio-dispose.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user