fix(playlists): "remove" inside the player targets the list it came from, reports failure, and keeps the cursor and focus honest
Deep-review R5 — five defects in the Remove button f76a2afb put inside the YT player:
- It removed from whichever playlist was selected by the time it was pressed, not the one the player
was launched from; the selection can change under an open player (the command palette opens over
it). The launch list is now part of the player snapshot, and the optimistic row removal only
touches the list actually on screen.
- A 403/404 failed in total silence: the catch claimed the global dialog had reported it, but `req`
deliberately leaves those to the caller. It now notifies, as moveItemTo already did (the silent
catch predates this round; the full-screen consumer did not). New key playlists.removeFailed, HU+EN.
- It computed the next item from the cursor as it was when the button was pressed, but prev/next and
auto-advance keep working under the confirm dialog: the viewer was thrown back onto a video they had
moved past. It reads the live cursor, and if they moved on it just drops the item from the queue.
- The button was disabled for the confirm's lifetime, so the dialog's focus restore hit a disabled
element and a keyboard user who cancelled was left on <body>, outside the aria-modal player. A ref
guards the double press instead.
- Its accessible name was a bare "Remove"; it now names its object, like the row control.
The call-site wiring (step on, drop from the queue, close on the last item, leave everything alone
when the removal did not happen) finally has tests. Verified on localdev in the real YT player: a row
reopened at the 136 s it had just stored; Cancel returned focus to the Remove button; a confirmed
removal stepped 1/3 -> 1/2. 10 mutations, each red on its assertion. Scratch list and positions reset.
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
// its promise in a module-level singleton, so this file must not share a module instance with a
|
||||
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
|
||||
// fake records what it was asked for — that is how these tests see where playback starts.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("./AddToPlaylist", () => ({ default: () => null }));
|
||||
vi.mock("./DownloadButton", () => ({ default: () => null }));
|
||||
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
|
||||
import { api, type Video } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -113,3 +114,138 @@ describe("PlayerModal — where each item starts", () => {
|
||||
expect(lastStart()?.start).toBe(830);
|
||||
});
|
||||
});
|
||||
|
||||
// --- "remove from this playlist", from inside the player ----------------------------------------
|
||||
const trash = (title: string) =>
|
||||
screen.getByRole("button", { name: `playlists.removeItemOf:Video ${title}` });
|
||||
const next = () => fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
||||
const prev = () => fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
|
||||
const settle = () => act(async () => {});
|
||||
|
||||
describe("PlayerModal — removing the playing video from its playlist", () => {
|
||||
const [A, B, C, D] = ["A", "B", "C", "D"].map((id) => vid(id, 0));
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
||||
video_id: id,
|
||||
position_seconds: 0,
|
||||
}));
|
||||
clock.current = 0;
|
||||
});
|
||||
|
||||
it("steps to the item that followed, and the removed one is gone from the queue", async () => {
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={B!}
|
||||
queue={[A!, B!, C!]}
|
||||
startIndex={1}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
fireEvent.click(trash("B"));
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
prev(); // …straight back to A: B is no longer there to step onto
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
});
|
||||
|
||||
it("closes the player when the last item goes", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={async () => true}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(trash("A"));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("leaves playback and the queue alone when the removal did not happen", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!, B!]}
|
||||
startIndex={0}
|
||||
onClose={onClose}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={async () => false}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(trash("A"));
|
||||
await settle();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(started.map((s) => s.videoId)).toEqual(["A"]);
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
prev(); // A is still in the queue
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
});
|
||||
|
||||
it("keeps playback where the viewer went while the confirm was open", async () => {
|
||||
// prev/next keep working under the confirm dialog; deciding from the cursor as it was when the
|
||||
// button was pressed threw the viewer back onto a video they had already moved past.
|
||||
let answer!: (ok: boolean) => void;
|
||||
renderModal(
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!, B!, C!, D!]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
fireEvent.click(trash("A"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
next();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
||||
await act(async () => answer(true));
|
||||
expect(lastStart()?.videoId).toBe("C");
|
||||
prev();
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
||||
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
|
||||
});
|
||||
|
||||
it("gives focus back to the Remove button when the confirm is cancelled", async () => {
|
||||
// The button used to be disabled for the confirm's whole lifetime, so the dialog's focus
|
||||
// restore landed on a disabled element — a no-op — and a keyboard user was left on <body>,
|
||||
// outside the aria-modal player.
|
||||
function WithConfirm() {
|
||||
const confirm = useConfirm();
|
||||
return (
|
||||
<PlayerModal
|
||||
video={A!}
|
||||
queue={[A!, B!]}
|
||||
startIndex={0}
|
||||
onClose={() => {}}
|
||||
onState={() => {}}
|
||||
onRemoveFromList={(v) =>
|
||||
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderModal(
|
||||
<ConfirmProvider>
|
||||
<WithConfirm />
|
||||
</ConfirmProvider>,
|
||||
);
|
||||
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
||||
trash("A").focus();
|
||||
fireEvent.click(trash("A"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "common.cancel" }));
|
||||
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user