Compare commits
7 Commits
6134dea044
...
ebed49237c
| Author | SHA1 | Date | |
|---|---|---|---|
| ebed49237c | |||
| dc2b22eaa5 | |||
| 2bb612ee07 | |||
| dc855ab8a0 | |||
| f976bdc2b7 | |||
| 1cf3361e59 | |||
| ada441300a |
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||||
*
|
*
|
||||||
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
* Implements yazi's `mgr.ratio` contract: three columns grow at
|
||||||
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
|
||||||
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
* so every list tab renders an identical, layout-stable shell. Columns use
|
||||||
* the ratio is exact regardless of content width — a column's content can
|
* `flexBasis={0}` so the ratio is exact regardless of content width — a
|
||||||
* never stretch its slot.
|
* column's content can never stretch its slot.
|
||||||
*
|
*
|
||||||
* Column semantics (per the yazi depth model):
|
* Column semantics (per the yazi depth model):
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||||
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
* KEEPS its 20% slot when blank (never collapses to width 0).
|
||||||
* Borderless (no left/right/top/bottom edge). Carries the single
|
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||||
* header row: the CURRENT column's title renders top-left in the
|
* header row: the CURRENT column's title renders top-left in the
|
||||||
* parent's slot (the panes above current/preview were removed).
|
* parent's slot (the panes above current/preview were removed).
|
||||||
@@ -184,7 +184,7 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
{/* ── parent (1/5) — previous-depth list; title row top-left ───────── */}
|
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={currentLabel}
|
label={currentLabel}
|
||||||
@@ -200,7 +200,7 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
border={["left", "right"]}
|
border={["left", "right"]}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (2/5) — hovered-item detail; no border, no header ────── */}
|
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
|
|||||||
@@ -239,10 +239,14 @@ export function Shell() {
|
|||||||
|
|
||||||
// ── Now-playing marquee ────────────────────────────────────────────────────
|
// ── Now-playing marquee ────────────────────────────────────────────────────
|
||||||
// The now-playing segment takes the full remaining status-bar width and
|
// The now-playing segment takes the full remaining status-bar width and
|
||||||
// marquee-scrolls on a 300ms timer when its text overflows; when it fits
|
// marquee-scrolls when its text overflows; when it fits (or the bar is too
|
||||||
// (or the bar is too narrow to show anything) it renders statically.
|
// narrow to show anything) it renders statically. Each pass scrolls at
|
||||||
|
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
||||||
|
// before scrolling again.
|
||||||
const dims = useTerminalDimensions();
|
const dims = useTerminalDimensions();
|
||||||
const GAP = 3;
|
const GAP = 3;
|
||||||
|
const SCROLL_STEP_MS = 150;
|
||||||
|
const SCROLL_HOLD_MS = 10_000;
|
||||||
const [scrollOffset, setScrollOffset] = createSignal(0);
|
const [scrollOffset, setScrollOffset] = createSignal(0);
|
||||||
const leftFixed = () =>
|
const leftFixed = () =>
|
||||||
modeLabel().length +
|
modeLabel().length +
|
||||||
@@ -270,10 +274,29 @@ export function Shell() {
|
|||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
if (!text || avail <= 0 || text.length <= avail) return;
|
if (!text || avail <= 0 || text.length <= avail) return;
|
||||||
const cycle = text.length + GAP - avail;
|
const cycle = text.length + GAP - avail;
|
||||||
const id = setInterval(() => {
|
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
||||||
setScrollOffset((o) => (o + 1) % cycle);
|
// then hold again before the next pass.
|
||||||
}, 150);
|
let holdId: ReturnType<typeof setTimeout> | null = null;
|
||||||
onCleanup(() => clearInterval(id));
|
let scrollId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const startHold = () => {
|
||||||
|
setScrollOffset(0);
|
||||||
|
holdId = setTimeout(() => {
|
||||||
|
scrollId = setInterval(() => {
|
||||||
|
const next = scrollOffset() + 1;
|
||||||
|
if (next >= cycle) {
|
||||||
|
clearInterval(scrollId!);
|
||||||
|
startHold();
|
||||||
|
} else {
|
||||||
|
setScrollOffset(next);
|
||||||
|
}
|
||||||
|
}, SCROLL_STEP_MS);
|
||||||
|
}, SCROLL_HOLD_MS);
|
||||||
|
};
|
||||||
|
startHold();
|
||||||
|
onCleanup(() => {
|
||||||
|
if (holdId) clearTimeout(holdId);
|
||||||
|
if (scrollId) clearInterval(scrollId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ export function TabListPane(props: { muted?: boolean }) {
|
|||||||
{TAB_ICON[tab]}
|
{TAB_ICON[tab]}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
|
||||||
<text fg={labelFg()} paddingLeft={1}>
|
<text fg={labelFg()} paddingLeft={1}>
|
||||||
{TAB_LABEL[tab]}
|
{TAB_LABEL[tab]}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
*
|
*
|
||||||
* parent | current | preview
|
* parent | current | preview
|
||||||
*
|
*
|
||||||
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
|
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
|
||||||
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||||
* nav model — which column is focused and where its list cursor lives. The
|
* nav model — which column is focused and where its list cursor lives. The
|
||||||
* parent/preview columns are always derived, never focused.
|
* parent/preview columns are always derived, never focused.
|
||||||
|
|||||||
@@ -267,31 +267,55 @@ function FeedPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={focusFg(index(), fi(), isActive())}
|
||||||
|
>
|
||||||
{index() === fi() ? marker() : " "}
|
{index() === fi() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={focusFg(index(), fi(), isActive())}
|
||||||
|
>
|
||||||
{item.episode.episodeNumber
|
{item.episode.episodeNumber
|
||||||
? `#${item.episode.episodeNumber} `
|
? `#${item.episode.episodeNumber} `
|
||||||
: ""}
|
: ""}
|
||||||
{item.episode.title}
|
{item.episode.title}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
{/* podcast name on its own row — readable at a glance; the
|
||||||
<text fg={index() === fi() ? theme.surface : theme.info}>
|
50% current pane fits it in full for typical names, and
|
||||||
{formatDate(item.episode.pubDate)}
|
truncate keeps the row one line tall either way */}
|
||||||
</text>
|
<box paddingLeft={2}>
|
||||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
<text
|
||||||
{formatDuration(item.episode.duration)}
|
wrapMode="none"
|
||||||
</text>
|
truncate
|
||||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
fg={index() === fi() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
{item.feed.customName || item.feed.podcast.title}
|
{item.feed.customName || item.feed.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === fi() ? theme.surface : theme.info}
|
||||||
|
>
|
||||||
|
{formatDate(item.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
|
>
|
||||||
|
{formatDuration(item.episode.duration)}
|
||||||
|
</text>
|
||||||
<Show when={nav.isSelected(item.episode.id)}>
|
<Show when={nav.isSelected(item.episode.id)}>
|
||||||
<text fg={theme.warning}>●</text>
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={downloadLabel(item.episode.id)}>
|
<Show when={downloadLabel(item.episode.id)}>
|
||||||
<text fg={downloadColor(item.episode.id)}>
|
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
|
||||||
{downloadLabel(item.episode.id)}
|
{downloadLabel(item.episode.id)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -362,26 +362,41 @@ export function MyShowsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={focusFg(index(), lf(), isActive())}
|
||||||
|
>
|
||||||
{index() === lf() ? marker() : " "}
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={focusFg(index(), lf(), isActive())}
|
||||||
|
>
|
||||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||||
{ep.title}
|
{ep.title}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
<text fg={index() === lf() ? theme.surface : theme.info}>
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === lf() ? theme.surface : theme.info}
|
||||||
|
>
|
||||||
{formatDate(ep.pubDate)}
|
{formatDate(ep.pubDate)}
|
||||||
</text>
|
</text>
|
||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={index() === lf() ? theme.surface : muted()}
|
||||||
|
>
|
||||||
{formatDuration(ep.duration)}
|
{formatDuration(ep.duration)}
|
||||||
</text>
|
</text>
|
||||||
<Show when={nav.isSelected(ep.id)}>
|
<Show when={nav.isSelected(ep.id)}>
|
||||||
<text fg={theme.warning}>●</text>
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={downloadLabel(ep.id)}>
|
<Show when={downloadLabel(ep.id)}>
|
||||||
<text fg={downloadColor(ep.id)}>
|
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
||||||
{downloadLabel(ep.id)}
|
{downloadLabel(ep.id)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -112,9 +112,13 @@ export function PlayerPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
{"P play/pause N next B prev < > seek h back"}
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
</text>
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
|
<text
|
||||||
|
fg={muted()}
|
||||||
|
content={"P play/pause N next B prev < > seek h back"}
|
||||||
|
/>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ function AddSourceForm() {
|
|||||||
width={25}
|
width={25}
|
||||||
textColor={theme.text}
|
textColor={theme.text}
|
||||||
focusedTextColor={theme.accent}
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
@@ -120,6 +121,7 @@ function AddSourceForm() {
|
|||||||
width={35}
|
width={35}
|
||||||
textColor={theme.text}
|
textColor={theme.text}
|
||||||
focusedTextColor={theme.accent}
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ function saveSources(sources: PodcastSource[]): void {
|
|||||||
saveSourcesToFile(sources);
|
saveSourcesToFile(sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when two episode lists hold the same episodes (id-set equality,
|
||||||
|
* order-insensitive). Refreshes compare fetched content against this so an
|
||||||
|
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
|
||||||
|
* "updated" sort — instead of reordering the list on every background
|
||||||
|
* refresh. */
|
||||||
|
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const ids = new Set(a.map((e) => e.id));
|
||||||
|
return b.every((e) => ids.has(e.id));
|
||||||
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
@@ -249,6 +260,26 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
||||||
|
* only when the content actually changed (see sameEpisodes). Returns the
|
||||||
|
* ORIGINAL array reference when nothing changed so callers skip
|
||||||
|
* persistence entirely — a refresh that fetched identical episodes must
|
||||||
|
* not re-sort the "updated" view. */
|
||||||
|
const applyRefreshedEpisodes = (
|
||||||
|
prev: Feed[],
|
||||||
|
feedId: string,
|
||||||
|
episodes: Episode[],
|
||||||
|
): Feed[] => {
|
||||||
|
let changed = false;
|
||||||
|
const updated = prev.map((f) => {
|
||||||
|
if (f.id !== feedId) return f;
|
||||||
|
if (sameEpisodes(f.episodes, episodes)) return f;
|
||||||
|
changed = true;
|
||||||
|
return { ...f, episodes, lastUpdated: new Date() };
|
||||||
|
});
|
||||||
|
return changed ? updated : prev;
|
||||||
|
};
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId);
|
const feed = getFeed(feedId);
|
||||||
@@ -259,10 +290,8 @@ function createFeedStore() {
|
|||||||
feedId,
|
feedId,
|
||||||
);
|
);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
if (updated !== prev) saveFeeds(updated);
|
||||||
);
|
|
||||||
saveFeeds(updated);
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,14 +300,35 @@ function createFeedStore() {
|
|||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh all feeds */
|
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
|
||||||
|
* atomic update. Per-feed incremental setFeeds re-sorted the list once
|
||||||
|
* per completion (each refresh bumped lastUpdated and the "updated" sort
|
||||||
|
* re-ran), which showed up as the list order flapping until the batch
|
||||||
|
* finished. */
|
||||||
const refreshAllFeeds = async () => {
|
const refreshAllFeeds = async () => {
|
||||||
setIsLoadingFeeds(true);
|
setIsLoadingFeeds(true);
|
||||||
try {
|
try {
|
||||||
const currentFeeds = feeds();
|
const currentFeeds = feeds();
|
||||||
for (const feed of currentFeeds) {
|
const results = await Promise.all(
|
||||||
await refreshFeed(feed.id);
|
currentFeeds.map(async (feed) => [
|
||||||
|
feed.id,
|
||||||
|
await fetchEpisodes(
|
||||||
|
feed.podcast.feedUrl,
|
||||||
|
MAX_EPISODES_REFRESH,
|
||||||
|
feed.id,
|
||||||
|
),
|
||||||
|
] as const),
|
||||||
|
);
|
||||||
|
setFeeds((prev) => {
|
||||||
|
let updated = prev;
|
||||||
|
for (const [feedId, episodes] of results) {
|
||||||
|
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
||||||
}
|
}
|
||||||
|
if (updated !== prev) saveFeeds(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
// Global auto-download: one idempotent pass after the batch.
|
||||||
|
runAutoDownload();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFeeds(false);
|
setIsLoadingFeeds(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ export function rootFrameFor(
|
|||||||
// terminal size — more robust than fixed percentages and exactly mirrors
|
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||||
//
|
//
|
||||||
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
|
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
|
||||||
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
|
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
|
||||||
// give `current` the combined 4/5.
|
// give `current` the combined 8/10 (80%).
|
||||||
export const PANE_RATIO = {
|
export const PANE_RATIO = {
|
||||||
parent: 1,
|
parent: 2,
|
||||||
current: 2,
|
current: 5,
|
||||||
preview: 2,
|
preview: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Number of *focusable* content panes per tab. The three visible columns
|
// Number of *focusable* content panes per tab. The three visible columns
|
||||||
|
|||||||
155
tests/feed-refresh.test.ts
Normal file
155
tests/feed-refresh.test.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Feed refresh order-stability regression test.
|
||||||
|
*
|
||||||
|
* My Shows / Feed sort by `lastUpdated` ("updated") by default, and every
|
||||||
|
* refresh bumped it unconditionally — so a startup refresh-all re-sorted the
|
||||||
|
* list once per feed as each fetch landed (order flapping until the batch
|
||||||
|
* finished). These tests pin the contract:
|
||||||
|
*
|
||||||
|
* 1. A refresh that fetches identical episodes does NOT bump lastUpdated —
|
||||||
|
* the feed object is untouched, so the list cannot reorder.
|
||||||
|
* 2. A refresh that fetches genuinely new episodes DOES bump lastUpdated.
|
||||||
|
* 3. refreshAllFeeds applies one atomic update: unchanged feeds keep their
|
||||||
|
* order and timestamps after a full refresh.
|
||||||
|
*
|
||||||
|
* The clock is mocked (fake timers) so the "did lastUpdated advance?" checks
|
||||||
|
* are deterministic — no real sleeps that would race under load.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it).
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-refresh-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
interface ServedEpisode {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
|
let feedAId = "";
|
||||||
|
|
||||||
|
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||||
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
|
const items = episodes
|
||||||
|
.map(
|
||||||
|
(ep, i) => `<item>
|
||||||
|
<title>${ep.title}</title>
|
||||||
|
<pubDate>${ep.date}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Test Show</title>
|
||||||
|
<description>Regression test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePodcast = (feedUrl: string): Podcast => ({
|
||||||
|
id: feedUrl,
|
||||||
|
title: "Test Show",
|
||||||
|
description: "Regression test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh with identical episodes does not bump lastUpdated", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||||
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||||
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/show-a.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
feedAId = feed!.id;
|
||||||
|
|
||||||
|
const before = store.getFeed(feedAId)!;
|
||||||
|
const beforeUpdated = before.lastUpdated.getTime();
|
||||||
|
|
||||||
|
// Advance the (mocked) clock, then refresh with identical content.
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(feedAId);
|
||||||
|
|
||||||
|
const after = store.getFeed(feedAId)!;
|
||||||
|
expect(after).toBe(before); // same object: no update applied at all
|
||||||
|
expect(after.lastUpdated.getTime()).toBe(beforeUpdated);
|
||||||
|
expect(after.episodes.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes.push({ title: "Ep 0 (new)", date: "2026-08-04T00:00:00Z" });
|
||||||
|
|
||||||
|
const before = store.getFeed(feedAId)!.lastUpdated.getTime();
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(feedAId);
|
||||||
|
|
||||||
|
const after = store.getFeed(feedAId)!;
|
||||||
|
expect(after.lastUpdated.getTime()).toBeGreaterThan(before);
|
||||||
|
expect(after.episodes.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
// Feed B: distinct URL, identical served content, so refreshing it is a
|
||||||
|
// no-op too.
|
||||||
|
const feedBUrl = `http://127.0.0.1:${server!.port}/show-b.xml`;
|
||||||
|
const feedB = await store.addFeed(makePodcast(feedBUrl), "test-source");
|
||||||
|
expect(feedB).not.toBeNull();
|
||||||
|
const feedBId = feedB!.id;
|
||||||
|
|
||||||
|
const orderBefore = store.getFilteredFeeds().map((f) => f.id);
|
||||||
|
const tsBefore: Record<string, number> = {};
|
||||||
|
for (const id of [feedAId, feedBId]) {
|
||||||
|
tsBefore[id] = store.getFeed(id)!.lastUpdated.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshAllFeeds();
|
||||||
|
|
||||||
|
expect(store.getFilteredFeeds().map((f) => f.id)).toEqual(orderBefore);
|
||||||
|
for (const id of [feedAId, feedBId]) {
|
||||||
|
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
207
tests/feed-row-wrap.test.tsx
Normal file
207
tests/feed-row-wrap.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Episode-row height regression — Feed rows must stay exactly 3 lines tall:
|
||||||
|
* title, podcast name, meta (date + duration + markers). Every line carries
|
||||||
|
* `wrapMode="none"` + `truncate` on flexible text and `flexShrink={0}` on
|
||||||
|
* fixed-width cells so Yoga can never shrink a text below its content width
|
||||||
|
* and wrap it — a wrapped row grows to 4+ lines and every entry below
|
||||||
|
* shifts its starting position while scrolling (the original bug).
|
||||||
|
*
|
||||||
|
* Rendered at 70 columns so the 35-col current pane is 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(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The exact row shape FeedPage renders: title, podcast name, meta. Static
|
||||||
|
// text, no store hooks — pure layout probe.
|
||||||
|
const FixedRow = () => (
|
||||||
|
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0}>❯</text>
|
||||||
|
<text wrapMode="none" truncate>
|
||||||
|
#674 - Scott Payne
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text wrapMode="none" truncate>
|
||||||
|
This Past Weekend w/ Theo Von
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text flexShrink={0}>Aug 10, 2026</text>
|
||||||
|
<text flexShrink={0}>3h 50m</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// A long title proves the truncate guard: without it the title wraps to
|
||||||
|
// multiple lines and the row grows past 3 lines.
|
||||||
|
const LongTitleFixed = () => (
|
||||||
|
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0}>❯</text>
|
||||||
|
<text wrapMode="none" truncate>
|
||||||
|
Out of Whiskey and Reaching for the Rotgut (Members Only #338)
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text wrapMode="none" truncate>
|
||||||
|
This Past Weekend w/ Theo Von
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text flexShrink={0}>Aug 10, 2026</text>
|
||||||
|
<text flexShrink={0}>3h 50m</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
|
||||||
|
// wraps at the shrunken width and the row grows.
|
||||||
|
const NaiveRow = () => (
|
||||||
|
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text>❯</text>
|
||||||
|
<text>Out of Whiskey and Reaching for the Rotgut (Members Only #338)</text>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text>This Past Weekend w/ Theo Von</text>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text>Aug 10, 2026</text>
|
||||||
|
<text>3h 50m</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function renderCurrent(
|
||||||
|
row: () => JSX.Element,
|
||||||
|
width = 70,
|
||||||
|
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<PaneRow
|
||||||
|
parent={null}
|
||||||
|
current={row}
|
||||||
|
preview={null}
|
||||||
|
currentLabel="List"
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ 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<void>)[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const c of cleanups) {
|
||||||
|
try {
|
||||||
|
await c();
|
||||||
|
} catch {
|
||||||
|
// renderer already torn down — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("episode row height in the current pane (70-wide → 35-col pane)", () => {
|
||||||
|
test("fixed row: title, podcast name and meta each render on exactly one line", async () => {
|
||||||
|
const { lines, destroy } = await renderCurrent(() => <FixedRow />);
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
// Podcast name is fully visible on its own line — the usability the
|
||||||
|
// dedicated row exists for.
|
||||||
|
expect(lines.filter((l) => l.includes("This Past Weekend w/ Theo Von"))).toHaveLength(1);
|
||||||
|
// Title and meta each on a single frame line (a wrapped date would
|
||||||
|
// split "Aug 10, 2026" across lines).
|
||||||
|
expect(lines.filter((l) => l.includes("Scott Payne"))).toHaveLength(1);
|
||||||
|
expect(lines.filter((l) => l.includes("Aug 10, 2026"))).toHaveLength(1);
|
||||||
|
expect(lines.filter((l) => l.includes("3h 50m"))).toHaveLength(1);
|
||||||
|
// The row occupies exactly the 3 content lines below the header.
|
||||||
|
const content = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.includes("Scott Payne") ||
|
||||||
|
l.includes("This Past Weekend") ||
|
||||||
|
l.includes("Aug 10,"),
|
||||||
|
);
|
||||||
|
expect(content).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("long title stays on one line (truncated), keeping the row at 3 lines", async () => {
|
||||||
|
const { lines, destroy } = await renderCurrent(() => <LongTitleFixed />);
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
// Truncated: the title head appears on exactly one line and the full
|
||||||
|
// title never appears on any line (middle-ellipsis clips it).
|
||||||
|
expect(lines.filter((l) => l.includes("Out of Whiske"))).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
lines.some((l) =>
|
||||||
|
l.includes(
|
||||||
|
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
// Podcast name and meta still each on one line — row total 3.
|
||||||
|
expect(lines.filter((l) => l.includes("This Past Weekend"))).toHaveLength(1);
|
||||||
|
expect(lines.filter((l) => l.includes("Aug 10,"))).toHaveLength(1);
|
||||||
|
const content = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.includes("Out of Whiske") ||
|
||||||
|
l.includes("This Past Weekend") ||
|
||||||
|
l.includes("Aug 10,"),
|
||||||
|
);
|
||||||
|
expect(content).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("naive row (pre-fix props) wraps the long title — the regression the test guards", async () => {
|
||||||
|
const { lines, destroy } = await renderCurrent(() => <NaiveRow />);
|
||||||
|
cleanups.push(destroy);
|
||||||
|
|
||||||
|
// The title's wrapped fragments span 3 frame lines instead of 1.
|
||||||
|
const titleFragments = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.includes("Out of Whiske") ||
|
||||||
|
l.includes("for the Rotgut") ||
|
||||||
|
l.includes("#338)"),
|
||||||
|
);
|
||||||
|
expect(titleFragments).toHaveLength(3);
|
||||||
|
// Row total: 3 title lines + podcast + meta = 5 content lines.
|
||||||
|
const content = lines.filter(
|
||||||
|
(l) =>
|
||||||
|
l.includes("Out of Whiske") ||
|
||||||
|
l.includes("for the Rotgut") ||
|
||||||
|
l.includes("#338)") ||
|
||||||
|
l.includes("This Past Weekend") ||
|
||||||
|
l.includes("Aug 10,"),
|
||||||
|
);
|
||||||
|
expect(content).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* PaneRow tests — the 1:2:2 parent|current|preview layout primitive.
|
* PaneRow tests — the 2:5:3 (20/50/30) parent|current|preview layout primitive.
|
||||||
*
|
*
|
||||||
* Verified through the opentui test renderer's captured frames (the same
|
* Verified through the opentui test renderer's captured frames (the same
|
||||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||||
* observable as rendered column widths.
|
* observable in rendered output, not in unit-testable state.
|
||||||
*
|
*
|
||||||
* • Unit: three columns render at 1:2:2 (e.g. 20/40/40 of 100) even when the
|
* • Unit: three columns render at 2:5:3 (e.g. 20/50/30 of 100) even when the
|
||||||
* parent and preview children are null, and the blank parent keeps its
|
* parent and preview children are null, and the blank parent keeps its
|
||||||
* slot with a muted placeholder.
|
* slot with a muted placeholder.
|
||||||
* • Integration: the current pane renders muted left/right border edges
|
* • Integration: the current pane renders muted left/right border edges
|
||||||
@@ -119,9 +119,9 @@ afterAll(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Unit: three columns at 1:2:2 regardless of null children ───────────────
|
// ── Unit: three columns at 2:5:3 regardless of null children ───────────────
|
||||||
describe("PaneRow layout", () => {
|
describe("PaneRow layout", () => {
|
||||||
test("renders three columns at 1:2:2 even with null parent/preview", async () => {
|
test("renders three columns at 2:5:3 even with null parent/preview", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>ITEM</text>,
|
current: () => <text>ITEM</text>,
|
||||||
@@ -132,15 +132,15 @@ describe("PaneRow layout", () => {
|
|||||||
const widths = columnWidths(spans);
|
const widths = columnWidths(spans);
|
||||||
expect(widths).toHaveLength(3);
|
expect(widths).toHaveLength(3);
|
||||||
const [p, c, v] = widths;
|
const [p, c, v] = widths;
|
||||||
// 100-wide row splits as 20 / 40 / 40 (1/5 : 2/5 : 2/5).
|
// 100-wide row splits as 20 / 50 / 30 (2 : 5 : 3 of 10).
|
||||||
expect(p).toBe(20);
|
expect(p).toBe(20);
|
||||||
expect(c).toBe(40);
|
expect(c).toBe(50);
|
||||||
expect(v).toBe(40);
|
expect(v).toBe(30);
|
||||||
// Exact 1:2:2 proportion (within 1 col rounding).
|
// Exact 2:5:3 proportion (within 1 col rounding).
|
||||||
expect(c).toBeGreaterThanOrEqual(p * 2 - 1);
|
expect(c).toBeGreaterThanOrEqual(Math.round(p * 2.5) - 1);
|
||||||
expect(c).toBeLessThanOrEqual(p * 2 + 1);
|
expect(c).toBeLessThanOrEqual(Math.round(p * 2.5) + 1);
|
||||||
expect(v).toBeGreaterThanOrEqual(p * 2 - 1);
|
expect(v).toBeGreaterThanOrEqual(Math.round(p * 1.5) - 1);
|
||||||
expect(v).toBeLessThanOrEqual(p * 2 + 1);
|
expect(v).toBeLessThanOrEqual(Math.round(p * 1.5) + 1);
|
||||||
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
|
// Parent keeps a visibly non-zero slot and renders the muted placeholder.
|
||||||
expect(p).toBeGreaterThan(4);
|
expect(p).toBeGreaterThan(4);
|
||||||
const body = spans.lines
|
const body = spans.lines
|
||||||
@@ -150,7 +150,7 @@ describe("PaneRow layout", () => {
|
|||||||
expect(body).toContain("ITEM");
|
expect(body).toContain("ITEM");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the 1/5 parent slot across widths (ratio stable)", async () => {
|
test("keeps the 20% parent slot across widths (ratio stable)", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
current: () => <text>x</text>,
|
current: () => <text>x</text>,
|
||||||
@@ -159,9 +159,9 @@ describe("PaneRow layout", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
const [p, c, v] = columnWidths(spans);
|
const [p, c, v] = columnWidths(spans);
|
||||||
expect(p).toBe(14); // 70 → 14 / 28 / 28
|
expect(p).toBe(14); // 70 → 14 / 35 / 21
|
||||||
expect(c).toBe(28);
|
expect(c).toBe(35);
|
||||||
expect(v).toBe(28);
|
expect(v).toBe(21);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,9 +181,9 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
|
||||||
// 100-wide row splits as 20 / 40 / 40: the current pane's edges sit at
|
// 100-wide row splits as 20 / 50 / 30: the current pane's edges sit at
|
||||||
// columns 20 and 59. No horizontal or corner glyphs — edges only.
|
// columns 20 and 69. No horizontal or corner glyphs — edges only.
|
||||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
|
|
||||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
focused: () => true,
|
focused: () => true,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
|
|
||||||
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
const { spans: spans2, destroy: destroy2 } = await renderPaneRow({
|
||||||
@@ -218,7 +218,7 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
focused: () => false,
|
focused: () => false,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy2);
|
cleanups.push(destroy2);
|
||||||
expect(borderColumns(spans2)).toEqual([20, 59]);
|
expect(borderColumns(spans2)).toEqual([20, 69]);
|
||||||
expect(frameText(spans2)).not.toMatch(boxGlyphs);
|
expect(frameText(spans2)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
preview: null,
|
preview: null,
|
||||||
});
|
});
|
||||||
cleanups.push(destroy);
|
cleanups.push(destroy);
|
||||||
expect(borderColumns(spans)).toEqual([20, 59]);
|
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user