fix: harden favorites drawer interactions

This commit is contained in:
wxs
2026-07-17 13:22:25 +08:00
parent ba4a1052e8
commit b973e1099d
2 changed files with 423 additions and 56 deletions
+185 -1
View File
@@ -6,6 +6,7 @@ import {
createFavoritesDrawer,
type FavoritesDrawerHandlers
} from "../src/content/market/favorites-drawer";
import { promptForFavoriteFolderName } from "../src/content/market/favorite-folder-dialog";
import type {
FavoriteCreator,
FavoriteFolder,
@@ -107,6 +108,14 @@ async function flush(): Promise<void> {
await Promise.resolve();
}
function createDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
describe("favorites-drawer", () => {
beforeEach(() => {
document.body.innerHTML = "";
@@ -157,6 +166,42 @@ describe("favorites-drawer", () => {
expect(document.activeElement).toBe(readTab());
});
test("uses an accessible icon close control and a wrapping footer for folder imports", () => {
const { controller } = createHarness();
controller.render(createState());
readTab().click();
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
const close = document.querySelector(
'[data-sces-favorites-drawer="close"]'
) as HTMLButtonElement;
const footer = readDrawer().querySelector("footer") as HTMLElement;
const importFolder = document.querySelector(
'[data-sces-favorites-drawer="import-folder"]'
) as HTMLButtonElement;
const importSelected = document.querySelector(
'[data-sces-favorites-drawer="import-selected"]'
) as HTMLButtonElement;
const actionRow = importFolder.parentElement as HTMLElement;
expect(close.textContent).toBe("x");
expect(close.getAttribute("aria-label")).toBe("关闭收藏夹");
expect(close.title).toBe("关闭收藏夹");
expect(footer.style.boxSizing).toBe("border-box");
expect(footer.style.minWidth).toBe("0px");
expect(footer.style.flexWrap).toBe("wrap");
expect(actionRow.style.boxSizing).toBe("border-box");
expect(actionRow.style.minWidth).toBe("0px");
expect(actionRow.style.flexWrap).toBe("wrap");
expect(actionRow.style.width).toBe("100%");
expect(importFolder.style.boxSizing).toBe("border-box");
expect(importFolder.style.minWidth).toBe("0px");
expect(importFolder.style.flex).toBe("1 1 100%");
expect(importSelected.style.boxSizing).toBe("border-box");
expect(importSelected.style.minWidth).toBe("0px");
expect(importSelected.style.flex).toBe("1 1 100%");
});
test("deduplicates creators in all view and shows their folder affiliations", () => {
const { controller } = createHarness();
controller.render(
@@ -210,6 +255,31 @@ describe("favorites-drawer", () => {
).toBeNull();
});
test.each([
{ folderId: "all", name: "全部的具体收藏夹" },
{ folderId: "", name: "空 ID 收藏夹" }
])("treats folder id $folderId as a concrete folder", ({ folderId, name }) => {
const folder = createFolder(folderId, name);
const { controller } = createHarness();
controller.render(
createState({
creators: [createCreator("111", "Alpha"), createCreator("222", "Beta")],
folders: [folder],
memberships: [createMembership("111", folderId)]
})
);
readTab().click();
click(`[data-sces-favorites-folder-id="${folderId}"]`);
expect(document.querySelectorAll('[data-sces-favorites-author-id]')).toHaveLength(1);
expect(readDrawer().textContent).toContain("Alpha");
expect(readDrawer().textContent).not.toContain("Beta");
expect(
document.querySelector('[data-sces-favorites-drawer="import-folder"]')
).not.toBeNull();
});
test("filters the current view by a trimmed, case-insensitive name query and shows an empty state", () => {
const { controller } = createHarness();
controller.render(createState());
@@ -306,6 +376,114 @@ describe("favorites-drawer", () => {
expect(onImportSelected).toHaveBeenCalledWith(["111"]);
});
test("locks duplicate imports and overlapping folder mutations until the active handler settles", async () => {
const selectedImport = createDeferred();
const deleteFolder = createDeferred();
const onImportSelected = vi.fn(() => selectedImport.promise);
const onDeleteFolder = vi.fn(() => deleteFolder.promise);
const onRenameFolder = vi.fn(async (_folderId: string) => {});
const { controller } = createHarness({
handlers: { onDeleteFolder, onImportSelected, onRenameFolder }
});
controller.render(createState());
readTab().click();
const checkbox = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
click('[data-sces-favorites-drawer="import-selected"]');
const pendingImport = document.querySelector(
'[data-sces-favorites-drawer="import-selected"]'
) as HTMLButtonElement;
pendingImport.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(onImportSelected).toHaveBeenCalledTimes(1);
expect(pendingImport.disabled).toBe(true);
selectedImport.resolve();
await flush();
click(`[data-sces-favorites-folder-id="${firstFolder.id}"]`);
click(`[data-sces-favorites-folder-delete="${firstFolder.id}"]`);
const pendingRename = document.querySelector(
`[data-sces-favorites-folder-rename="${firstFolder.id}"]`
) as HTMLButtonElement;
pendingRename.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
expect(onDeleteFolder).toHaveBeenCalledWith(firstFolder.id);
expect(onRenameFolder).not.toHaveBeenCalled();
expect(pendingRename.disabled).toBe(true);
deleteFolder.resolve();
await flush();
});
test("preserves focus for a folder switch and a creator checkbox render", () => {
const { controller } = createHarness();
controller.render(createState());
readTab().click();
const folder = document.querySelector(
`[data-sces-favorites-folder-id="${firstFolder.id}"]`
) as HTMLButtonElement;
folder.focus();
folder.click();
const activeFolder = document.querySelector(
`[data-sces-favorites-folder-id="${firstFolder.id}"]`
) as HTMLButtonElement;
expect(document.activeElement).toBe(activeFolder);
const checkbox = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
checkbox.focus();
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
const activeCheckbox = document.querySelector(
'[data-sces-favorites-select-author-id="author-1"]'
) as HTMLInputElement;
expect(document.activeElement).toBe(activeCheckbox);
});
test("leaves the drawer open when an active folder-name dialog handles Escape", async () => {
let dialogResult: Promise<string | null> | undefined;
const onCreateFolder = vi.fn(async () => {
dialogResult = promptForFavoriteFolderName(document, {
title: "新建收藏夹"
});
await dialogResult;
});
const { controller } = createHarness({ handlers: { onCreateFolder } });
controller.render(createState());
readTab().click();
const search = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
search.value = "Alpha";
search.dispatchEvent(new Event("input", { bubbles: true }));
click('[data-sces-favorites-drawer="create-folder"]');
await flush();
expect(
document.querySelector('[data-sces-favorite-folder-dialog="root"]')
).not.toBeNull();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await expect(dialogResult).resolves.toBeNull();
await flush();
const restoredSearch = document.querySelector(
'[data-sces-favorites-drawer="search"]'
) as HTMLInputElement;
expect(readDrawer().hidden).toBe(false);
expect(readTab().getAttribute("aria-expanded")).toBe("true");
expect(restoredSearch.value).toBe("Alpha");
expect(document.activeElement).toBe(restoredSearch);
});
test("calls create, rename, and delete handlers with the matching folder id", async () => {
const onCreateFolder = vi.fn(async () => {});
const onRenameFolder = vi.fn(async (_folderId: string) => {});
@@ -317,7 +495,9 @@ describe("favorites-drawer", () => {
readTab().click();
click('[data-sces-favorites-drawer="create-folder"]');
await flush();
click(`[data-sces-favorites-folder-rename="${secondFolder.id}"]`);
await flush();
click(`[data-sces-favorites-folder-delete="${secondFolder.id}"]`);
await flush();
@@ -412,7 +592,11 @@ describe("favorites-drawer", () => {
controller.dispose();
expect(document.querySelector('[data-sces-favorites-tab="button"]')).toBeNull();
expect(document.querySelector('[data-sces-favorites-drawer="root"]')).toBeNull();
expect(removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
expect(removeEventListener).toHaveBeenCalledWith(
"keydown",
expect.any(Function),
true
);
const unrelatedButton = document.createElement("button");
unrelatedButton.type = "button";