diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1901c399..4eca0b3c 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -122,8 +122,39 @@ const literalA11yText = { }; }, }; +// dnd-kit's screen-reader text is hard-coded English and names an item by its id; three of the four +// sortable lists shipped it that way under a Hungarian UI. `ui/sortableA11y` is the one place that +// replaces it, and this is what keeps the next sortable list from skipping it. +const sortableA11y = { + meta: { + type: "problem", + messages: { + context: "A needs `accessibility={useSortableA11y(...)}` (ui/sortableA11y).", + item: "Use `useSortableItem` from ui/sortableA11y: it announces the role in the user's language.", + }, + }, + create(context) { + const own = context.filename.split("\\").join("/").endsWith("components/ui/sortableA11y.ts"); + return { + JSXOpeningElement(node) { + if (node.name.type !== "JSXIdentifier" || node.name.name !== "DndContext") return; + if ( + !node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "accessibility") + ) + context.report({ node, messageId: "context" }); + }, + ImportDeclaration(node) { + if (own || node.source.value !== "@dnd-kit/sortable") return; + for (const sp of node.specifiers) + if (sp.type === "ImportSpecifier" && sp.imported.name === "useSortable") + context.report({ node: sp, messageId: "item" }); + }, + }; + }, +}; const localRules = { rules: { + "sortable-a11y": sortableA11y, "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled, "literal-a11y-text": literalA11yText, @@ -158,6 +189,7 @@ export default tseslint.config( "local/jsx-comment-text": "error", "local/focus-safe-disabled": "error", "local/literal-a11y-text": "error", + "local/sortable-a11y": "error", "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], // tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a // finding is reported once, by one tool, and this config stays about hooks + refresh safety. diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 32fe0501..e87d6b1b 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -9,12 +9,7 @@ import { useSensors, type DragEndEvent, } from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api"; import Modal from "./ui/Modal"; @@ -25,6 +20,7 @@ import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { useUndoable } from "../lib/useUndoable"; import UndoToolbar from "./ui/UndoToolbar"; import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name @@ -43,7 +39,7 @@ function ViewRow({ onSelect: () => void; }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: view.id, }); return ( @@ -147,6 +143,12 @@ export default function PersonalViewsModal() { ) : feedCaptured; + const dndA11y = useSortableA11y((id) => ({ + title: views.find((v) => v.id === id)?.name ?? "", + pos: views.findIndex((v) => v.id === id) + 1, + total: views.length, + })); + async function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -179,7 +181,12 @@ export default function PersonalViewsModal() { {views.length === 0 && (

{t("views.empty")}

)} - + v.id)} strategy={verticalListSortingStrategy} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 894a3d9e..adfbe755 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -15,7 +15,6 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -69,6 +68,7 @@ import { alignClass } from "./ui/tableAlign"; import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns"; import { GRIP_KEY, playlistColumns } from "./playlistColumns"; import { FocusSafeButton } from "./ui/FocusSafeButton"; +import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; // The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a // body that is still a drag-reorderable @dnd-kit list. @@ -115,7 +115,7 @@ function Row({ menuProps: Record; }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: video.id, disabled: dragDisabled, }); @@ -415,32 +415,15 @@ export default function Playlists() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1; - const titleOf = (id: string | number) => shownRef.current.find((v) => v.id === id)?.title ?? ""; - const total = () => shownRef.current.length; - const screenReaderInstructions = { draggable: t("playlists.dndInstructions") }; - const announcements = { - onDragStart: ({ active }: { active: { id: string | number } }) => - t("playlists.dndPickedUp", { - title: titleOf(active.id), - pos: posOf(active.id), - total: total(), - }), - onDragOver: ({ active }: { active: { id: string | number } }) => - t("playlists.dndMovedOver", { - title: titleOf(active.id), - pos: posOf(active.id), - total: total(), - }), - onDragEnd: ({ active }: { active: { id: string | number } }) => - t("playlists.dndDropped", { - title: titleOf(active.id), - pos: posOf(active.id), - total: total(), - }), - onDragCancel: ({ active }: { active: { id: string | number } }) => - t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }), - }; + // Named and placed from `shownRef`, read at announcement time (see ui/sortableA11y). + const dndA11y = useSortableA11y((id) => { + const rows = shownRef.current; + return { + title: rows.find((v) => v.id === id)?.title ?? "", + pos: rows.findIndex((v) => v.id === id) + 1, + total: rows.length, + }; + }); const plName = (p: { kind: string; name: string }) => playlistName(p, t); @@ -1072,7 +1055,7 @@ export default function Playlists() { onDragEnd={onDragEnd} // dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could // drag; now that the keyboard can, they ARE the instructions for the feature. - accessibility={{ screenReaderInstructions, announcements }} + accessibility={dndA11y} > {/* Not clipped, so the header's filter popovers can overflow the table box. */}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index 321a7245..cf29bac9 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -15,7 +15,6 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, - useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -41,6 +40,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import { FocusSafeButton } from "./ui/FocusSafeButton"; import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits"; +import { useSortableA11y, useSortableItem } from "./ui/sortableA11y"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -146,7 +146,7 @@ function DragHandle({ title, ...props }: { title: string } & Record; ctx: Ctx }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id: block.id, disabled: ctx.busy, }); @@ -458,6 +458,23 @@ export default function PlexPlaylistView({ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + // A whole block (a movie, a show) sorts among the blocks; an episode only within its own show. + const dndA11y = useSortableA11y((id) => { + const at = blocks.findIndex((b) => b.id === id); + if (at >= 0) { + const b = blocks[at]!; + return { + title: b.kind === "show" ? b.title : b.item.title, + pos: at + 1, + total: blocks.length, + }; + } + const show = blocks.find((b) => b.kind === "show" && b.items.some((it) => it.id === id)); + const eps = show?.kind === "show" ? show.items : []; + const ep = eps.findIndex((it) => it.id === id); + return { title: eps[ep]?.title ?? "", pos: ep + 1, total: eps.length }; + }); + function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -581,7 +598,12 @@ export default function PlexPlaylistView({ ) : items.length === 0 ? (

{t("plex.playlist.empty")}

) : ( - + b.id)} strategy={verticalListSortingStrategy}>
{blocks.map((b) => diff --git a/frontend/src/components/ui/PanelGroup.tsx b/frontend/src/components/ui/PanelGroup.tsx index 361d64d8..487a06de 100644 --- a/frontend/src/components/ui/PanelGroup.tsx +++ b/frontend/src/components/ui/PanelGroup.tsx @@ -1,8 +1,8 @@ import { type ReactNode, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react"; -import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useSortableItem } from "./sortableA11y"; // One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag // handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three @@ -35,7 +35,7 @@ export default function PanelGroup({ children: ReactNode; }) { const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({ id, disabled: !editing, }); diff --git a/frontend/src/components/ui/PanelGroups.tsx b/frontend/src/components/ui/PanelGroups.tsx index 82d494cb..42e1cae5 100644 --- a/frontend/src/components/ui/PanelGroups.tsx +++ b/frontend/src/components/ui/PanelGroups.tsx @@ -16,6 +16,7 @@ import { } from "@dnd-kit/sortable"; import type { PanelLayout } from "../../lib/panelLayout"; import PanelGroup from "./PanelGroup"; +import { useSortableA11y } from "./sortableA11y"; export type PanelItem = { id: string; @@ -70,6 +71,11 @@ export default function PanelGroups({ function toggleHidden(id: string) { setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); } + const dndA11y = useSortableA11y((id) => ({ + title: byId.get(String(id))?.title ?? "", + pos: renderedIds.indexOf(String(id)) + 1, + total: renderedIds.length, + })); function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; @@ -80,7 +86,12 @@ export default function PanelGroups({ } return ( - +
{renderedIds.map((id) => ( diff --git a/frontend/src/components/ui/sortableA11y.dom.test.tsx b/frontend/src/components/ui/sortableA11y.dom.test.tsx new file mode 100644 index 00000000..bb59587a --- /dev/null +++ b/frontend/src/components/ui/sortableA11y.dom.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { Active, Over } from "@dnd-kit/core"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, o?: Record) => + o ? `${key}:${o.title}@${o.pos}/${o.total}` : key, + }), +})); + +import { useSortableA11y } from "./sortableA11y"; + +const rows = ["a", "b", "c", "d"]; +const describeRow = (id: string | number) => ({ + title: `Row ${id}`, + pos: rows.indexOf(String(id)) + 1, + total: rows.length, +}); +const active = (id: string) => ({ id }) as unknown as Active; +const over = (id: string) => ({ id }) as unknown as Over; + +// The lists here reorder on DROP, so the dragged row's own index stays its starting place for the +// whole drag. The YouTube playlist read exactly that, and announced "moved to position 1" at every +// step and "dropped at position 1" wherever the row went. The place is the `over` row's slot. +describe("useSortableA11y", () => { + const { announcements, screenReaderInstructions } = renderHook(() => useSortableA11y(describeRow)) + .result.current; + + it("speaks the user's language, not dnd-kit's English", () => { + expect(screenReaderInstructions.draggable).toBe("common.dnd.instructions"); + }); + + it("announces where the row WOULD land while it moves, and where it did land", () => { + expect(announcements.onDragStart({ active: active("a") })).toBe( + "common.dnd.pickedUp:Row a@1/4", + ); + expect(announcements.onDragOver({ active: active("a"), over: over("c") })).toBe( + "common.dnd.movedOver:Row a@3/4", + ); + expect(announcements.onDragEnd({ active: active("a"), over: over("d") })).toBe( + "common.dnd.dropped:Row a@4/4", + ); + }); + + it("says a drop outside the list, or Escape, put the row back where it was", () => { + expect(announcements.onDragEnd({ active: active("b"), over: null })).toBe( + "common.dnd.cancelled:Row b@2/4", + ); + expect(announcements.onDragCancel({ active: active("b"), over: null })).toBe( + "common.dnd.cancelled:Row b@2/4", + ); + }); +}); diff --git a/frontend/src/components/ui/sortableA11y.ts b/frontend/src/components/ui/sortableA11y.ts new file mode 100644 index 00000000..158a9534 --- /dev/null +++ b/frontend/src/components/ui/sortableA11y.ts @@ -0,0 +1,65 @@ +import { useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import type { Announcements, ScreenReaderInstructions, UniqueIdentifier } from "@dnd-kit/core"; +import { useSortable } from "@dnd-kit/sortable"; + +// What a screen reader hears while a keyboard user reorders a list. dnd-kit's defaults are +// hard-coded English ("To pick up a draggable item, press the space bar…", "Draggable item 10357 +// was dropped", a role of "sortable"), and only the YouTube playlist had replaced them — the Plex +// playlist, the saved-views list and the side-panel groups spoke English under a Hungarian UI and +// named an item by its internal id. Every sortable list goes through here instead +// (`local` lint: a `` needs `accessibility`, `useSortable` comes from this file). +// +// A position is where the item WOULD land — the `over` item's slot — not where it started: the +// lists here reorder on drop, so reading the dragged item's own index announced its old place at +// every step and at the drop (the YouTube playlist did exactly that). + +/** One sortable item as the announcements name it: its title, and its 1-based place in the list it + * sorts within (a Plex episode sorts within its show, not the whole playlist). */ +export interface SortableSubject { + title: string; + pos: number; + total: number; +} + +/** The `accessibility` prop for a ``. `describe` is read at announcement time, so it + * may close over the latest render without making the announcements a render dependency. */ +export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubject): { + screenReaderInstructions: ScreenReaderInstructions; + announcements: Announcements; +} { + const { t } = useTranslation(); + const latest = useRef(describe); + latest.current = describe; + return useMemo(() => { + // Spread into a fresh object: i18next's options type is an index signature, which a named + // interface does not satisfy. + const at = (id: UniqueIdentifier) => ({ ...latest.current(id) }); + const landing = (active: UniqueIdentifier, over: UniqueIdentifier) => ({ + ...at(active), + pos: at(over).pos, + }); + return { + screenReaderInstructions: { draggable: t("common.dnd.instructions") }, + announcements: { + onDragStart: ({ active }) => t("common.dnd.pickedUp", at(active.id)), + onDragOver: ({ active, over }) => + over ? t("common.dnd.movedOver", landing(active.id, over.id)) : undefined, + onDragEnd: ({ active, over }) => + over + ? t("common.dnd.dropped", landing(active.id, over.id)) + : t("common.dnd.cancelled", at(active.id)), + onDragCancel: ({ active }) => t("common.dnd.cancelled", at(active.id)), + }, + }; + }, [t]); +} + +/** `useSortable`, with its role announced in the user's language instead of dnd-kit's "sortable". */ +export function useSortableItem(args: Parameters[0]) { + const { t } = useTranslation(); + return useSortable({ + ...args, + attributes: { ...args.attributes, roleDescription: t("common.dnd.roleDescription") }, + }); +} diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 9db81db3..10a97819 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -35,5 +35,13 @@ "moreRefine": "{{count}} more — refine with search", "matchMode": "Match", "removeNamed": "Remove {{name}}", - "resetNamed": "Reset {{name}}" + "resetNamed": "Reset {{name}}", + "dnd": { + "instructions": "Press space to pick up an item, the arrow keys to move it, space again to drop it, or escape to cancel.", + "roleDescription": "movable item", + "pickedUp": "Picked up “{{title}}”, position {{pos}} of {{total}}.", + "movedOver": "“{{title}}” moved to position {{pos}} of {{total}}.", + "dropped": "“{{title}}” dropped at position {{pos}} of {{total}}.", + "cancelled": "Move cancelled; “{{title}}” is back at position {{pos}}." + } } diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 78277d3c..d4483693 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -113,11 +113,6 @@ "confirmDeleteLinked": "Delete the playlist “{{name}}”? It is synced with YouTube — choose whether to delete it there as well. This can't be undone.", "dragBlockedBuiltin": "This list's order is fixed — it can't be reordered by hand.", "deleteBoth": "Delete here and on YouTube", - "dndInstructions": "Press space to pick up a video, the arrow keys to move it, space again to drop it, or escape to cancel.", - "dndPickedUp": "Picked up “{{title}}”, position {{pos}} of {{total}}.", - "dndMovedOver": "“{{title}}” moved to position {{pos}} of {{total}}.", - "dndDropped": "“{{title}}” dropped at position {{pos}} of {{total}}.", - "dndCancelled": "Move cancelled; “{{title}}” is back at position {{pos}}.", "viewGrouped": "grouped by channel", "viewGroupedAnd": "grouped by channel, {{sort}}", "viewDiffers": "Temporary view ({{view}}) — the playlist's saved order is unchanged. Save it to keep this order.", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 2c9978d5..57054942 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -35,5 +35,13 @@ "moreRefine": "még {{count}} — finomíts kereséssel", "matchMode": "Egyezés", "removeNamed": "{{name}}: eltávolítás", - "resetNamed": "{{name}}: alaphelyzet" + "resetNamed": "{{name}}: alaphelyzet", + "dnd": { + "instructions": "Szóközzel felveszed az elemet, a nyilakkal mozgatod, újabb szóközzel leteszed, Escape-pel megszakítod.", + "roleDescription": "mozgatható elem", + "pickedUp": "„{{title}}” felvéve, {{pos}}. a(z) {{total}} közül.", + "movedOver": "„{{title}}” a(z) {{pos}}. helyre került a(z) {{total}} közül.", + "dropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.", + "cancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre." + } } diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index bd2812dc..8b6c104d 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -113,11 +113,6 @@ "confirmDeleteLinked": "Törlöd a(z) „{{name}}” listát? A YouTube-bal szinkronban van — válaszd ki, hogy ott is törlődjön-e. Nem vonható vissza.", "dragBlockedBuiltin": "Ennek a listának kötött a sorrendje — kézzel nem rendezhető át.", "deleteBoth": "Törlés itt és a YouTube-on", - "dndInstructions": "Szóközzel felveszed a videót, a nyilakkal mozgatod, újabb szóközzel leteszed, Escape-pel megszakítod.", - "dndPickedUp": "„{{title}}” felvéve, {{pos}}. a(z) {{total}} közül.", - "dndMovedOver": "„{{title}}” a(z) {{pos}}. helyre került a(z) {{total}} közül.", - "dndDropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.", - "dndCancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre.", "viewGrouped": "csatorna szerint csoportosítva", "viewGroupedAnd": "csatorna szerint csoportosítva, {{sort}}", "viewDiffers": "Ideiglenes nézet ({{view}}) — a lista mentett sorrendje változatlan. Mentsd, ha ez maradjon.", diff --git a/frontend/src/lib/sortableA11y.contract.test.ts b/frontend/src/lib/sortableA11y.contract.test.ts new file mode 100644 index 00000000..c54eb41f --- /dev/null +++ b/frontend/src/lib/sortableA11y.contract.test.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from "node:url"; + +import { ESLint } from "eslint"; +import { describe, expect, it } from "vitest"; + +// `local/sortable-a11y` (eslint.config.js): every sortable list speaks the user's language to a +// screen reader, through ui/sortableA11y. Three of four lists had shipped dnd-kit's English +// defaults. A rule that matches nothing looks exactly like a clean codebase, so this asserts it fires. +const RULE = "local/sortable-a11y"; +const FRONTEND = fileURLToPath(new URL("../../", import.meta.url)); + +async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise { + const eslint = new ESLint({ cwd: FRONTEND }); + const [result] = await eslint.lintText(source, { filePath }); + return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length; +} + +describe("a sortable list", () => { + it("is refused without translated screen-reader text", async () => { + expect(await hits(`export const A = () => {}} />;\n`)).toBe(1); + expect( + await hits(`import { useSortable } from "@dnd-kit/sortable";\nexport { useSortable };\n`), + ).toBe(1); + }); + + it("is fine through ui/sortableA11y, which alone may wrap dnd-kit's hook", async () => { + expect(await hits(`export const B = ({ a }) => ;\n`)).toBe(0); + expect( + await hits( + `import { useSortable } from "@dnd-kit/sortable";\nexport { useSortable };\n`, + "src/components/ui/sortableA11y.ts", + ), + ).toBe(0); + }); +});