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,6 +8,7 @@ import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-libra
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
selectedId: 1 as number | null,
|
||||
player: null as null | {
|
||||
video: { id: string; position_seconds: number };
|
||||
@@ -26,6 +27,10 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
vi.mock("./PlaylistsProvider", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./usePlaylistActions", () => ({ usePlaylistActions: () => ({ remove: vi.fn() }) }));
|
||||
@@ -37,7 +42,7 @@ vi.mock("./PlayerModal", () => ({
|
||||
}));
|
||||
|
||||
import Playlists from "./Playlists";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../lib/progressCache";
|
||||
|
||||
@@ -87,11 +92,13 @@ function mount(lists: Record<number, Video[]>) {
|
||||
);
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
return render(
|
||||
const tree = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<Playlists />
|
||||
</QueryClientProvider>,
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(tree());
|
||||
return { rerender: () => view.rerender(tree()) };
|
||||
}
|
||||
|
||||
const play = async (title: string) =>
|
||||
@@ -100,6 +107,7 @@ const play = async (title: string) =>
|
||||
beforeEach(() => {
|
||||
h.selectedId = 1;
|
||||
h.player = null;
|
||||
h.notify.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -122,3 +130,38 @@ describe("Playlists — the row the player is launched with", () => {
|
||||
await waitFor(() => expect(h.player?.video.position_seconds).toBe(830));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — removing a video from inside the player", () => {
|
||||
it("removes it from the list the player was launched from, even after the selection moved", async () => {
|
||||
// The selection can change under an open player (the command palette opens over it); the
|
||||
// removal read the LIVE selection, so it took the video out of a list the viewer never touched.
|
||||
const remove = vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
|
||||
const { rerender } = mount({ 1: [vid("V", 0)], 2: [vid("V", 0), vid("W", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
h.selectedId = 2;
|
||||
rerender();
|
||||
await screen.findByRole("button", { name: "card.play — Video W" }); // list 2 is on screen now
|
||||
await act(async () => {
|
||||
await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
expect(remove).toHaveBeenCalledWith(1, "V");
|
||||
// …and the list on screen keeps its own copy of V: nothing was taken out of it.
|
||||
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so when the removal fails because the list is gone", async () => {
|
||||
// `req` leaves 403/404 to the caller; the catch assumed a global dialog had already reported
|
||||
// it, so a confirmed removal failed in total silence behind the full-screen player.
|
||||
vi.spyOn(api, "removeFromPlaylist").mockRejectedValue(new HttpError(404, "Unknown playlist"));
|
||||
mount({ 1: [vid("V", 0)] });
|
||||
await play("Video V");
|
||||
await waitFor(() => expect(h.player).not.toBeNull());
|
||||
let done: boolean | undefined;
|
||||
await act(async () => {
|
||||
done = await h.player!.onRemoveFromList!(vid("V", 0));
|
||||
});
|
||||
expect(done).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user