fix: harden favorites drawer interactions
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user